// // 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) } @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(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) { 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) } }