1 contributor
486 lines | 18.29kb
//
//  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 CryptoSwift
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 {
    case UM25C
    case UM34C
    case TC66C
}

var modelRadios: [Model : BluetoothRadio] = [
    .UM25C : .BT18,
    .UM34C : .BT18,
    .TC66C : .PW0316
]

var ModelByPeriferalName: [String : Model] = [
    "UM25C" : .UM25C,
    "UM34C" : .UM34C,
    "TC66C" : .TC66C,
    "PW0316" : .TC66C
]

var colorForModel: [Model : Color] = [
    .UM25C : .blue,
    .UM34C : .yellow,
    .TC66C : .black
]


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 colorForModel[model]!
        }
    }

    @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
    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( Data([0xF0]), expectedResponseLength: 130)
            case .UM34C:
                btSerial.write( Data([0xF0]), expectedResponseLength: 130)
            case .TC66C:
                btSerial.write( "bgetva\r\n".data(using: String.Encoding.ascii)!, 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:
            parseUMData(from: buffer)
        case .UM34C:
            parseUMData(from: buffer)
        case .TC66C:
            parseTCData( from: buffer )
        }
        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()
    }
    
    func parseUMData(from buffer: Data) {
        modelNumber = UInt16( bigEndian: buffer.value( from: 0 ) )
        switch model {
            case .UM25C:
                voltage = Double( UInt16( bigEndian: buffer.value( from: 2) ) )/1000
                current = Double( UInt16( bigEndian: buffer.value( from: 4) ) )/10000
            case .UM34C:
                voltage = Double( UInt16( bigEndian: buffer.value( from: 2) ) )/100
                current = Double( UInt16( bigEndian: buffer.value( from: 4) ) )/1000
            case .TC66C:
                track("\(name) - This is not possible!")

        }
        power = Double( UInt32( bigEndian: buffer.value( from: 6) ) )/1000
        temperatureCelsius = Double( UInt16( bigEndian: buffer.value( from: 10) ) )
        temperatureFahrenheit = Double( UInt16( bigEndian: buffer.value( from: 12) ) )
        selectedDataGroup = UInt8(UInt16( bigEndian: buffer.value( from: 14) ) )
        for index in stride(from: 0, through: 9, by: 1) {
            let offset = 16 + index * 8
            dataGroupRecords[index] = DataGroupRecord(ah: Double (UInt32( bigEndian: buffer.value( from: offset ) ) )/1000, wh:Double (UInt32( bigEndian: buffer.value( from: offset + 4 ) ) )/1000)
        }
        usbPlusVoltage = Double( UInt16( bigEndian: buffer.value( from: 96) ) )/100
        usbMinusVoltage = Double( UInt16( bigEndian: buffer.value( from: 98) ) )/100
        chargerTypeIndex = UInt16( bigEndian: buffer.value( from: 100) )
        recordedAH = Double (UInt32( bigEndian: buffer.value( from: 102 ) ) )/1000
        recordedWH = Double (UInt32( bigEndian: buffer.value( from: 106 ) ) )/1000
        recordingTreshold = Double (UInt16( bigEndian: buffer.value( from: 110 ) ) )/100
        recordingDuration = UInt32( bigEndian: buffer.value( from: 112 ) )
        recording = UInt16( bigEndian: buffer.value( from: 116 ) ) == 1
        if screenTimeoutTimestamp < dataDumpRequestTimestamp {
            let tmpValue = Int (UInt16( bigEndian: buffer.value( from: 118 ) ) )
            if screenTimeout != tmpValue {
                screenTimeout = tmpValue
            }
        } else {
            track("\(name) - Skip updating screenTimeout (changed after request).")
        }

        if screenBrightnessTimestamp < dataDumpRequestTimestamp {
            let tmpValue = Int (UInt16( bigEndian: buffer.value( from: 120 ) ) )
            if screenBrightness != tmpValue {
                screenBrightness = tmpValue
            }
        } else {
            track("\(name) - Skip updating screenBrightness (changed after request).")
        }

        currentScreen = UInt16(bigEndian: buffer.value( from: 126 ) )
        loadResistance = Double ( UInt32( bigEndian: buffer.value( from: 122 ) ) )/10
        //        track("\(name) - Model Number = \(modelNumber)")
        //        track("\(name) - chargerTypeIndex = \(chargerTypeIndex)")
    }
    
    private func validatePac ( id: UInt8, pac: Data ) -> Bool {
        //track("\(name) - \(id) - \(pac.hexEncodedStringValue)")
        let expectedHeader = "pac\(id)".data(using: String.Encoding.ascii)
        let pacHeader = pac.subdata(from: 0, length: 4)
        let expectedCRC = UInt16( bigEndian: pac.subdata(from: 0, length: 60).crc16(seed: 0xFFFF).value( from: 0 ) )
        let pacCRC = UInt16( littleEndian: pac.value(from: 60) )
        return expectedHeader == pacHeader && expectedCRC == pacCRC
    }
    
    func parseTCData(from buffer: Data) {
        do {
            let key: [UInt8] = [
                0x58, 0x21, 0xfa, 0x56, 0x01, 0xb2, 0xf0, 0x26,
                0x87, 0xff, 0x12, 0x04, 0x62, 0x2a, 0x4f, 0xb0,
                0x86, 0xf4, 0x02, 0x60, 0x81, 0x6f, 0x9a, 0x0b,
                0xa7, 0xf1, 0x06, 0x61, 0x9a, 0xb8, 0x72, 0x88
            ]
            let cipher = try! AES(key: key, blockMode: ECB())
            let decryptedBuffer = Data( try cipher.decrypt(buffer.bytes) )
                    let pac1: Data = decryptedBuffer.subdata( from: 0, length: 64 )
                    if validatePac(id: 1, pac: pac1) {
                        let pac2: Data = decryptedBuffer.subdata( from: 64, length: 64 )
                        if  validatePac(id: 2, pac: pac2) {
                            let pac3: Data = decryptedBuffer.subdata( from: 128, length: 64 )
                            if validatePac(id: 3, pac: pac3) {
            //                    let modelName = pac1.subdata(from: 4, length: 4).asciiString
            //                    track("\(name) - Model: \(modelName)")
            //                    let firmwareVersion = pac1.subdata(from: 8, length: 4).asciiString
            //                    track("\(name) - Firmware Version: \(firmwareVersion)")
            //                    let serialNumber = UInt32( littleEndian: pac1.value( from: 12 ) )
            //                    track("\(name) - Serial Number: \(serialNumber)")
            //                    let powerCycleCount = UInt32( littleEndian: pac1.value( from: 44 ) )
            //                    track("\(name) - Power Cycle Count: \(powerCycleCount)")
                                voltage = Double( UInt32( littleEndian: pac1.value( from: 48) ) )/10000
                                current = Double( UInt32( littleEndian: pac1.value( from: 52) ) )/100000
                                power = Double( UInt32( littleEndian: pac1.value( from: 56) ) )/10000
                                loadResistance = Double( UInt32( littleEndian: pac2.value( from: 4) ) )/10
                                for index in stride(from: 0, through: 1, by: 1) {
                                    let offset = 8 + index * 8
                                    dataGroupRecords[index] = DataGroupRecord(ah: Double (UInt32( littleEndian: pac2.value( from: offset ) ) )/1000, wh:Double (UInt32( littleEndian: pac2.value( from: offset + 40 ) ) )/1000)
                                }
                                temperatureCelsius = Double( UInt32( littleEndian: pac2.value( from: 28 ) )) * ( UInt32( littleEndian: pac2.value( from: 24 ) ) == 1 ? -1 : 1 )
                                usbPlusVoltage = Double( UInt32( littleEndian: pac2.value( from: 32) ) )/100
                                usbMinusVoltage = Double( UInt32( littleEndian: pac2.value( from: 36) ) )/100
                                return
                            }
                        }
                    }
                    track("\(name) - Invalid data")

        } catch {
            track("\(name) - Error: \(error)")
        }
    }
        
    func nextScreen() {
        switch model {
        case .UM25C:
            commandQueue.append( Data( [0xF1] ) )
        case .UM34C:
            commandQueue.append( Data( [0xF1] ) )
        case .TC66C:
            commandQueue.append( "bnextp\r\n".data(using: String.Encoding.ascii)! )
        }
    }
    
    func rotateScreen() {
        switch model {
        case .UM25C:
            commandQueue.append( Data( [0xF2] ) )
        case .UM34C:
            commandQueue.append( Data( [0xF2] ) )
        case .TC66C:
            commandQueue.append( "brotat\r\n".data(using: String.Encoding.ascii)! )
        }
    }
    
    func previousScreen() {
        switch model {
        case .UM25C:
            commandQueue.append( Data( [0xF3] ) )
        case .UM34C:
            commandQueue.append( Data( [0xF3] ) )
        case .TC66C:
            commandQueue.append( "blastp\r\n".data(using: String.Encoding.ascii)! )
        }
    }
    
    func clear() {
        guard model != .TC66C else { return }
        commandQueue.append( Data( [0xF4] ) )
    }
    
    func clear(group id: UInt8) {
        guard model != .TC66C else { return }
        commandQueue.append( Data( [0xA0 | id] ) )
        clear()
        commandQueue.append( Data( [0xA0 | selectedDataGroup] ) )
    }
    
    func selectDataGroup ( id: UInt8) {
        track("\(name) - \(id)")
        selectedDataGroup = id
        commandQueue.append( Data( [0xA0 | selectedDataGroup] ) )
    }
    
    private func setSceeenBrightness ( to value: UInt8) {
        track("\(name) - \(value)")
        guard model != .TC66C else { return }
        commandQueue.append( Data( [0xD0 | value] ) )
    }
    private func setScreenSaverTimeout ( to value: UInt8) {
        track("\(name) - \(value)")
        guard model != .TC66C else { return }
        commandQueue.append( Data( [0xE0 | value]) )
    }
    func setrecordingTreshold ( to value: UInt8) {
        guard model != .TC66C else { return }
        commandQueue.append( Data( [0xB0 + 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)
    }
}