// // 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) } }