1 contributor
471 lines | 15.449kb
//
//  File.swift
//  USB Meter
//
//  Created by Bogdan Timofte on 03/03/2020.
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
//
//MARK: Store and documentation: https://www.aliexpress.com/item/32968303350.html
//MARK: Protocol: https://sigrok.org/wiki/RDTech_UM_series
//MARK: Pithon Code: https://github.com/rfinnie/rdserialtool
//MARK: HM-10 Code: https://github.com/hoiberg/HM10-BluetoothSerial-iOS
//MARK: Package dependency https://github.com/krzyzanowskim/CryptoSwift

import CoreBluetooth
import SwiftUI

/**
 Supprted USB Meters
 # UM25C
 # TC66
 * Reverse Engineering
 [UM Series](https://sigrok.org/wiki/RDTech_UM_series)
 [TC66C](https://sigrok.org/wiki/RDTech_TC66C)
 */
enum Model: CaseIterable {
    case UM25C
    case UM34C
    case TC66C
}

class Meter : NSObject, ObservableObject, Identifiable {

    enum OperationalState: Int, Comparable {
        case notPresent
        case peripheralNotConnected
        case peripheralConnectionPending
        case peripheralConnected
        case peripheralReady
        case comunicating
        case dataIsAvailable

        static func < (lhs: OperationalState, rhs: OperationalState) -> Bool {
            return lhs.rawValue < rhs.rawValue
        }
    }

    @Published var operationalState = OperationalState.peripheralNotConnected {
        didSet {
            switch operationalState {
            case .notPresent:
                break
            case .peripheralNotConnected:
                if enableAutoConnect {
                    track("\(name) - Reconnecting...")
                    btSerial.connect()
                }
            case .peripheralConnectionPending:
                break
            case .peripheralConnected:
                break
            case .peripheralReady:
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // Change `2.0` to the desired number of seconds.
                    self.dataDumpRequest()
                }
            case .comunicating:
                break
            case .dataIsAvailable:
                break
            }
        }
    }
    
    static func operationalColor(for state: OperationalState) -> Color {
        switch state {
        case .notPresent:
            return .red
        case .peripheralNotConnected:
            return .blue
        case .peripheralConnectionPending:
            return .yellow
        case .peripheralConnected:
            return .yellow
        case .peripheralReady:
            return .orange
        case .comunicating:
            return .orange
        case .dataIsAvailable:
            return .green
        }
    }

    private var wdTimer: Timer?

    @Published var lastSeen = Date() {
        didSet {
            wdTimer?.invalidate()
            if operationalState == .peripheralNotConnected {
                wdTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false, block: {_ in
                    track("\(self.name) - Lost advertisments...")
                    self.operationalState = .notPresent
                })
            } else if operationalState == .notPresent {
               operationalState = .peripheralNotConnected
            }
        }
    }

    var uuid: UUID
    var model: Model
    var modelString: String
    
    var name: String {
        didSet {
            appData.meterNames[btSerial.macAddress.description] = name
        }
    }
    
    var color : Color {
        get {
            return model.color
        }
    }

    var capabilities: MeterCapabilities {
        model.capabilities
    }

    var availableDataGroupIDs: [UInt8] {
        capabilities.availableDataGroupIDs
    }

    var supportsDataGroupCommands: Bool {
        capabilities.supportsDataGroupCommands
    }

    var supportsUMSettings: Bool {
        capabilities.supportsScreenSettings
    }

    var supportsRecordingThreshold: Bool {
        capabilities.supportsRecordingThreshold
    }

    var supportsFahrenheit: Bool {
        capabilities.supportsFahrenheit
    }

    var supportsChargerDetection: Bool {
        capabilities.supportsChargerDetection
    }

    var chargerTypeDescription: String {
        capabilities.chargerTypeDescription(for: chargerTypeIndex)
    }

    var deviceModelSummary: String {
        let baseName = reportedModelName.isEmpty ? modelString : reportedModelName
        if modelNumber != 0 {
            return "\(baseName) (\(modelNumber))"
        }
        return baseName
    }

    @Published var btSerial: BluetoothSerial
    
    @Published var measurements = Measurements()

    private var commandQueue: [Data] = []
    private var dataDumpRequestTimestamp = Date()
    
    class DataGroupRecord {
        @Published var ah: Double
        @Published var wh: Double
        init(ah: Double, wh: Double) {
            self.ah = ah
            self.wh = wh
        }
    }
    @Published var selectedDataGroup: UInt8 = 0
    @Published var dataGroupRecords: [Int : DataGroupRecord] = [:]

    @Published var screenBrightness: Int = -1 {
        didSet {
            if oldValue != screenBrightness {
                screenBrightnessTimestamp = Date()
                if oldValue != -1 {
                    setSceeenBrightness(to: UInt8(screenBrightness))
                }
            }
        }
    }
    private var screenBrightnessTimestamp = Date()

    @Published var screenTimeout: Int = -1 {
        didSet {
            if oldValue != screenTimeout {
                screenTimeoutTimestamp = Date()
                if oldValue != -1 {
                    setScreenSaverTimeout(to: UInt8(screenTimeout))
                }
            }
        }
    }
    private var screenTimeoutTimestamp = Date()
    
    @Published var voltage: Double = 0
    @Published var current: Double = 0
    @Published var power: Double = 0
    @Published var temperatureCelsius: Double = 0
    @Published var temperatureFahrenheit: Double = 0
    @Published var usbPlusVoltage: Double = 0
    @Published var usbMinusVoltage: Double = 0
    @Published var recordedAH: Double = 0
    @Published var recordedWH: Double = 0
    @Published var recording: Bool = false
    @Published var recordingTreshold: Double = 0 /* MARK: Seteaza inutil la pornire {
        didSet {
            if recordingTreshold != oldValue {
                setrecordingTreshold(to: (recordingTreshold*100).uInt8Value)
            }
        }
    } */
    @Published var currentScreen: UInt16 = 0
    @Published var recordingDuration: UInt32 = 0
    @Published var loadResistance: Double = 0
    @Published var modelNumber: UInt16 = 0
    @Published var chargerTypeIndex: UInt16 = 0
    @Published var reportedModelName: String = ""
    @Published var firmwareVersion: String = ""
    @Published var serialNumber: UInt32 = 0
    @Published var bootCount: UInt32 = 0
    private var enableAutoConnect: Bool = false
        
    init ( model: Model, with serialPort: BluetoothSerial ) {
        uuid = serialPort.peripheral.identifier
        //dataStore.meterUUIDS.append(serialPort.peripheral.identifier)
        modelString = serialPort.peripheral.name!
        self.model = model
        btSerial = serialPort
        name = appData.meterNames[serialPort.macAddress.description] ?? serialPort.macAddress.description
        super.init()
        btSerial.delegate = self
        //name = dataStore.meterNames[macAddress.description] ?? peripheral.name!
        for index in stride(from: 0, through: 9, by: 1) {
            dataGroupRecords[index] = DataGroupRecord(ah:0, wh: 0)
        }
    }
    
    func dataDumpRequest() {
        if commandQueue.isEmpty {
            switch model {
            case .UM25C:
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
            case .UM34C:
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
            case .TC66C:
                btSerial.write(TC66Protocol.snapshotRequest, expectedResponseLength: 192)
            }
            dataDumpRequestTimestamp = Date()
            // track("\(name) - Request sent!")
        } else {
            track("Request delayed for: \(commandQueue.first!.hexEncodedStringValue)")
            btSerial.write( commandQueue.first! )
            commandQueue.removeFirst()
            DispatchQueue.main.asyncAfter( deadline: .now() + 1 )  {
                self.dataDumpRequest()
            }
        }
    }

    /**
     received data parser
     - parameter buffer cotains response for data dump request
     - Decription metod for TC66C AES ECB response  found [here](https:github.com/krzyzanowskim/CryptoSwift/issues/693)
     */
    func parseData ( from buffer: Data) {
        //track("\(name)")
        switch model {
        case .UM25C:
            do {
                apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
            } catch {
                track("\(name) - Error: \(error)")
            }
        case .UM34C:
            do {
                apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
            } catch {
                track("\(name) - Error: \(error)")
            }
        case .TC66C:
            do {
                apply(tc66Snapshot: try TC66Protocol.parseSnapshot(from: buffer))
            } catch {
                track("\(name) - Error: \(error)")
            }
        }
        measurements.addValues(timestamp: dataDumpRequestTimestamp, power: power, voltage: voltage, current: current)
//        DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
//            //track("\(name) - Scheduled new request.")
//        }
        operationalState = .dataIsAvailable
        dataDumpRequest()
    }

    private func apply(umSnapshot snapshot: UMSnapshot) {
        modelNumber = snapshot.modelNumber
        voltage = snapshot.voltage
        current = snapshot.current
        power = snapshot.power
        temperatureCelsius = snapshot.temperatureCelsius
        temperatureFahrenheit = snapshot.temperatureFahrenheit
        selectedDataGroup = snapshot.selectedDataGroup
        for (index, record) in snapshot.dataGroupRecords {
            dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
        }
        usbPlusVoltage = snapshot.usbPlusVoltage
        usbMinusVoltage = snapshot.usbMinusVoltage
        chargerTypeIndex = snapshot.chargerTypeIndex
        recordedAH = snapshot.recordedAH
        recordedWH = snapshot.recordedWH
        recordingTreshold = snapshot.recordingThreshold
        recordingDuration = snapshot.recordingDuration
        recording = snapshot.recording

        if screenTimeoutTimestamp < dataDumpRequestTimestamp {
            if screenTimeout != snapshot.screenTimeout {
                screenTimeout = snapshot.screenTimeout
            }
        } else {
            track("\(name) - Skip updating screenTimeout (changed after request).")
        }

        if screenBrightnessTimestamp < dataDumpRequestTimestamp {
            if screenBrightness != snapshot.screenBrightness {
                screenBrightness = snapshot.screenBrightness
            }
        } else {
            track("\(name) - Skip updating screenBrightness (changed after request).")
        }

        currentScreen = snapshot.currentScreen
        loadResistance = snapshot.loadResistance
    }

    private func apply(tc66Snapshot snapshot: TC66Snapshot) {
        reportedModelName = snapshot.modelName
        firmwareVersion = snapshot.firmwareVersion
        serialNumber = snapshot.serialNumber
        bootCount = snapshot.bootCount
        voltage = snapshot.voltage
        current = snapshot.current
        power = snapshot.power
        loadResistance = snapshot.loadResistance
        for (index, record) in snapshot.dataGroupRecords {
            dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
        }
        temperatureCelsius = snapshot.temperatureCelsius
        usbPlusVoltage = snapshot.usbPlusVoltage
        usbMinusVoltage = snapshot.usbMinusVoltage
    }
        
    func nextScreen() {
        switch model {
        case .UM25C:
            commandQueue.append(UMProtocol.nextScreen)
        case .UM34C:
            commandQueue.append(UMProtocol.nextScreen)
        case .TC66C:
            commandQueue.append(TC66Protocol.nextPage)
        }
    }
    
    func rotateScreen() {
        switch model {
        case .UM25C:
            commandQueue.append(UMProtocol.rotateScreen)
        case .UM34C:
            commandQueue.append(UMProtocol.rotateScreen)
        case .TC66C:
            commandQueue.append(TC66Protocol.rotateScreen)
        }
    }
    
    func previousScreen() {
        switch model {
        case .UM25C:
            commandQueue.append(UMProtocol.previousScreen)
        case .UM34C:
            commandQueue.append(UMProtocol.previousScreen)
        case .TC66C:
            commandQueue.append(TC66Protocol.previousPage)
        }
    }
    
    func clear() {
        guard supportsDataGroupCommands else { return }
        commandQueue.append(UMProtocol.clearCurrentGroup)
    }
    
    func clear(group id: UInt8) {
        guard supportsDataGroupCommands else { return }
        commandQueue.append(UMProtocol.selectDataGroup(id))
        clear()
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
    }
    
    func selectDataGroup ( id: UInt8) {
        guard supportsDataGroupCommands else { return }
        track("\(name) - \(id)")
        selectedDataGroup = id
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
    }
    
    private func setSceeenBrightness ( to value: UInt8) {
        track("\(name) - \(value)")
        guard supportsUMSettings else { return }
        commandQueue.append(UMProtocol.setScreenBrightness(value))
    }
    private func setScreenSaverTimeout ( to value: UInt8) {
        track("\(name) - \(value)")
        guard supportsUMSettings else { return }
        commandQueue.append(UMProtocol.setScreenSaverTimeout(value))
    }
    func setrecordingTreshold ( to value: UInt8) {
        guard supportsRecordingThreshold else { return }
        commandQueue.append(UMProtocol.setRecordingThreshold(value))
    }
    
    /**
     Connect to meter.
     1. It calls BluetoothSerial.connect
     */
    func connect() {
        enableAutoConnect = true
        btSerial.connect()
    }
    
    /**
     Disconnect from meter.
        It calls BluetoothSerial.disconnect
     */
    func disconnect() {
        enableAutoConnect = false
        btSerial.disconnect()
    }
}

extension Meter : SerialPortDelegate {

    func opertionalStateChanged(to serialPortOperationalState: BluetoothSerial.OperationalState) {
        lastSeen = Date()
        //track("\(name) - \(serialPortOperationalState)")
        switch serialPortOperationalState {
        case .peripheralNotConnected:
            operationalState = .peripheralNotConnected
        case .peripheralConnectionPending:
            operationalState = .peripheralConnectionPending
        case .peripheralConnected:
            operationalState = .peripheralConnected
        case .peripheralReady:
            operationalState = .peripheralReady
        }
    }
    
    func didReceiveData(_ data: Data) {
        lastSeen = Date()
        operationalState = .comunicating
        parseData(from: data)
    }
}