// // 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 } enum TemperatureUnitPreference: String, CaseIterable, Identifiable { case celsius case fahrenheit var id: String { rawValue } var title: String { switch self { case .celsius: return "Celsius" case .fahrenheit: return "Fahrenheit" } } var symbol: String { switch self { case .celsius: return "℃" case .fahrenheit: return "℉" } } } private extension TemperatureUnitPreference { var localeTitle: String { switch self { case .celsius: return "System (Celsius)" case .fahrenheit: return "System (Fahrenheit)" } } } enum ChargeRecordState { case waitingForStart case active case completed } class Meter : NSObject, ObservableObject, Identifiable { private static func shouldLogOperationalStateTransition(from oldValue: OperationalState, to newValue: OperationalState) -> Bool { switch (oldValue, newValue) { case (.comunicating, .dataIsAvailable), (.dataIsAvailable, .comunicating): return false default: return true } } 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 { guard operationalState != oldValue else { return } if Self.shouldLogOperationalStateTransition(from: oldValue, to: operationalState) { track("\(name) - Operational state changed from \(oldValue) to \(operationalState)") } switch operationalState { case .notPresent: cancelPendingDataDumpRequest(reason: "meter missing") break case .peripheralNotConnected: cancelPendingDataDumpRequest(reason: "peripheral disconnected") if !commandQueue.isEmpty { track("\(name) - Clearing \(commandQueue.count) queued commands after disconnect") commandQueue.removeAll() } if enableAutoConnect { track("\(name) - Reconnecting...") btSerial.connect() } case .peripheralConnectionPending: cancelPendingDataDumpRequest(reason: "connection pending") break case .peripheralConnected: cancelPendingDataDumpRequest(reason: "services not ready yet") break case .peripheralReady: scheduleDataDumpRequest(after: 0.5, reason: "peripheral ready") 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? 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 private var isSyncingNameFromStore = false @Published var name: String { didSet { guard !isSyncingNameFromStore else { return } guard oldValue != name else { return } appData.setMeterName(name, for: btSerial.macAddress.description) } } var color : Color { get { return model.color } } var capabilities: MeterCapabilities { model.capabilities } var availableDataGroupIDs: [UInt8] { capabilities.availableDataGroupIDs } var supportsDataGroupCommands: Bool { capabilities.supportsDataGroupCommands } var supportsRecordingView: Bool { capabilities.supportsRecordingView } var supportsUMSettings: Bool { capabilities.supportsScreenSettings } var supportsRecordingThreshold: Bool { capabilities.supportsRecordingThreshold } var reportsCurrentScreenIndex: Bool { capabilities.reportsCurrentScreenIndex } var showsDataGroupEnergy: Bool { capabilities.showsDataGroupEnergy } var highlightsActiveDataGroup: Bool { if model == .TC66C { return hasObservedActiveDataGroup } return capabilities.highlightsActiveDataGroup } var supportsFahrenheit: Bool { capabilities.supportsFahrenheit } var supportsManualTemperatureUnitSelection: Bool { model == .TC66C } var supportsChargerDetection: Bool { capabilities.supportsChargerDetection } var dataGroupsTitle: String { capabilities.dataGroupsTitle } var documentedWorkingVoltage: String { capabilities.documentedWorkingVoltage } var chargerTypeDescription: String { capabilities.chargerTypeDescription(for: chargerTypeIndex) } var temperatureUnitDescription: String { if supportsManualTemperatureUnitSelection { return "Device-defined" } return systemTemperatureUnitPreference.localeTitle } var primaryTemperatureDescription: String { let value = displayedTemperatureValue.format(decimalDigits: 0) if supportsManualTemperatureUnitSelection { return "\(value)°" } return "\(value)\(systemTemperatureUnitPreference.symbol)" } var secondaryTemperatureDescription: String? { nil } var displayedTemperatureValue: Double { if supportsManualTemperatureUnitSelection { return temperatureCelsius } switch systemTemperatureUnitPreference { case .celsius: return displayedTemperatureCelsius case .fahrenheit: return displayedTemperatureFahrenheit } } private var displayedTemperatureCelsius: Double { if supportsManualTemperatureUnitSelection { switch tc66TemperatureUnitPreference { case .celsius: return temperatureCelsius case .fahrenheit: return (temperatureCelsius - 32) * 5 / 9 } } return temperatureCelsius } private var displayedTemperatureFahrenheit: Double { if supportsManualTemperatureUnitSelection { switch tc66TemperatureUnitPreference { case .celsius: return (temperatureCelsius * 9 / 5) + 32 case .fahrenheit: return temperatureCelsius } } if supportsFahrenheit, temperatureFahrenheit.isFinite { return temperatureFahrenheit } return (temperatureCelsius * 9 / 5) + 32 } private var systemTemperatureUnitPreference: TemperatureUnitPreference { let locale = Locale.autoupdatingCurrent if #available(iOS 16.0, *) { switch locale.measurementSystem { case .us: return .fahrenheit default: return .celsius } } let regionCode = locale.regionCode ?? "" let fahrenheitRegions: Set = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"] return fahrenheitRegions.contains(regionCode) ? .fahrenheit : .celsius } var currentScreenDescription: String { guard reportsCurrentScreenIndex else { return "Page Controls" } if let label = capabilities.screenDescription(for: currentScreen) { return "Screen \(currentScreen): \(label)" } return "Screen \(currentScreen)" } var deviceModelName: String { if !reportedModelName.isEmpty { return reportedModelName } return model.canonicalName } var deviceModelSummary: String { let baseName = deviceModelName if modelNumber != 0 { return "\(baseName) (\(modelNumber))" } return baseName } var recordingDurationDescription: String { let totalSeconds = Int(recordingDuration) let hours = totalSeconds / 3600 let minutes = (totalSeconds % 3600) / 60 let seconds = totalSeconds % 60 if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) } return String(format: "%02d:%02d", minutes, seconds) } var chargeRecordDurationDescription: String { let totalSeconds = Int(chargeRecordDuration) let hours = totalSeconds / 3600 let minutes = (totalSeconds % 3600) / 60 let seconds = totalSeconds % 60 if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) } return String(format: "%02d:%02d", minutes, seconds) } var chargeRecordTimeRange: ClosedRange? { guard let start = chargeRecordStartTimestamp else { return nil } let end = chargeRecordEndTimestamp ?? chargeRecordLastTimestamp guard let end else { return nil } return start...end } var chargeRecordStatusText: String { switch chargeRecordState { case .waitingForStart: return "Waiting" case .active: return "Active" case .completed: return "Completed" } } var chargeRecordStatusColor: Color { switch chargeRecordState { case .waitingForStart: return .secondary case .active: return .red case .completed: return .green } } var dataGroupsHint: String? { if model == .TC66C { if hasObservedActiveDataGroup { return "The active memory is inferred from the totals that are currently increasing." } return "The device exposes two read-only memories. The active memory is inferred once one total starts increasing." } return capabilities.dataGroupsHint } func dataGroupLabel(for id: UInt8) -> String { supportsDataGroupCommands ? "\(id)" : "M\(Int(id) + 1)" } var recordingThresholdHint: String? { capabilities.recordingThresholdHint } var btSerial: BluetoothSerial var measurements = Measurements() private var commandQueue: [Data] = [] private var dataDumpRequestTimestamp = Date() private var pendingDataDumpWorkItem: DispatchWorkItem? class DataGroupRecord { var ah: Double var wh: Double init(ah: Double, wh: Double) { self.ah = ah self.wh = wh } } private(set) var selectedDataGroup: UInt8 = 0 private(set) var dataGroupRecords: [Int : DataGroupRecord] = [:] private(set) var chargeRecordAH: Double = 0 private(set) var chargeRecordWH: Double = 0 private(set) var chargeRecordDuration: TimeInterval = 0 @Published var chargeRecordStopThreshold: Double = 0.05 @Published var tc66TemperatureUnitPreference: TemperatureUnitPreference = .celsius { didSet { guard supportsManualTemperatureUnitSelection else { return } guard oldValue != tc66TemperatureUnitPreference else { return } appData.setTemperatureUnitPreference(tc66TemperatureUnitPreference, for: btSerial.macAddress.description) } } @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() private(set) var voltage: Double = 0 private(set) var current: Double = 0 private(set) var power: Double = 0 private(set) var temperatureCelsius: Double = 0 private(set) var temperatureFahrenheit: Double = 0 private(set) var usbPlusVoltage: Double = 0 private(set) var usbMinusVoltage: Double = 0 private(set) var recordedAH: Double = 0 private(set) var recordedWH: Double = 0 private(set) var recording: Bool = false @Published var recordingTreshold: Double = 0 { didSet { guard recordingTreshold != oldValue else { return } if isApplyingRecordingThresholdFromDevice { return } recordingThresholdTimestamp = Date() guard recordingThresholdLoadedFromDevice else { return } setrecordingTreshold(to: (recordingTreshold * 100).uInt8Value) } } private(set) var currentScreen: UInt16 = 0 private(set) var recordingDuration: UInt32 = 0 private(set) var loadResistance: Double = 0 private(set) var modelNumber: UInt16 = 0 private(set) var chargerTypeIndex: UInt16 = 0 private(set) var reportedModelName: String = "" private(set) var firmwareVersion: String = "" private(set) var serialNumber: UInt32 = 0 private(set) var bootCount: UInt32 = 0 private var enableAutoConnect: Bool = false private var recordingThresholdTimestamp = Date() private var recordingThresholdLoadedFromDevice = false private var isApplyingRecordingThresholdFromDevice = false private(set) var chargeRecordState = ChargeRecordState.waitingForStart private var chargeRecordStartTimestamp: Date? private var chargeRecordEndTimestamp: Date? private var chargeRecordLastTimestamp: Date? private var chargeRecordLastCurrent: Double = 0 private var chargeRecordLastPower: Double = 0 private let volatileMemoryDecreaseEpsilon = 0.0005 private let initiatedVolatileMemoryResetGraceWindow: TimeInterval = 12 private var hasSeenUMSnapshot = false private var hasObservedActiveDataGroup = false private var hasSeenTC66Snapshot = false private var pendingVolatileMemoryResetIgnoreCount = 0 private var pendingVolatileMemoryResetDeadline: Date? private var liveDataChanged = false @discardableResult private func setIfChanged(_ keyPath: ReferenceWritableKeyPath, to value: T) -> Bool { guard self[keyPath: keyPath] != value else { return false } self[keyPath: keyPath] = value liveDataChanged = true return true } private func updateDataGroupRecord(index: Int, ah: Double, wh: Double) { if let existing = dataGroupRecords[index] { if existing.ah != ah { existing.ah = ah; liveDataChanged = true } if existing.wh != wh { existing.wh = wh; liveDataChanged = true } } else { dataGroupRecords[index] = DataGroupRecord(ah: ah, wh: wh) liveDataChanged = true } } 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.meterName(for: serialPort.macAddress.description) ?? serialPort.macAddress.description super.init() btSerial.delegate = self reloadTemperatureUnitPreference() //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 reloadTemperatureUnitPreference() { guard supportsManualTemperatureUnitSelection else { return } let persistedPreference = appData.temperatureUnitPreference(for: btSerial.macAddress.description) if tc66TemperatureUnitPreference != persistedPreference { tc66TemperatureUnitPreference = persistedPreference } } func updateNameFromStore(_ newName: String) { guard newName != name else { return } isSyncingNameFromStore = true name = newName isSyncingNameFromStore = false } private func cancelPendingDataDumpRequest(reason: String) { guard let pendingDataDumpWorkItem else { return } track("\(name) - Cancel scheduled data request (\(reason))") pendingDataDumpWorkItem.cancel() self.pendingDataDumpWorkItem = nil } private func scheduleDataDumpRequest(after delay: TimeInterval, reason: String) { cancelPendingDataDumpRequest(reason: "reschedule") let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingDataDumpWorkItem = nil self.dataDumpRequest() } pendingDataDumpWorkItem = workItem track("\(name) - Schedule data request in \(String(format: "%.2f", delay))s (\(reason))") DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } private func noteInitiatedVolatileMemoryResetIfNeeded(for groupID: UInt8) { guard groupID == 0 else { return } pendingVolatileMemoryResetIgnoreCount += 1 pendingVolatileMemoryResetDeadline = Date().addingTimeInterval(initiatedVolatileMemoryResetGraceWindow) track("\(name) - Will ignore the next volatile memory drop caused by an app reset request.") } private func pendingInitiatedVolatileMemoryResetIsActive(at timestamp: Date) -> Bool { guard let pendingVolatileMemoryResetDeadline else { return false } guard pendingVolatileMemoryResetIgnoreCount > 0 else { self.pendingVolatileMemoryResetDeadline = nil return false } guard timestamp <= pendingVolatileMemoryResetDeadline else { track("\(name) - Expiring stale volatile memory reset ignore state.") pendingVolatileMemoryResetIgnoreCount = 0 self.pendingVolatileMemoryResetDeadline = nil return false } return true } private func shouldIgnoreVolatileMemoryDrop(at timestamp: Date) -> Bool { guard pendingInitiatedVolatileMemoryResetIsActive(at: timestamp) else { return false } pendingVolatileMemoryResetIgnoreCount -= 1 if pendingVolatileMemoryResetIgnoreCount == 0 { pendingVolatileMemoryResetDeadline = nil } track("\(name) - Ignoring volatile memory drop after an app-initiated reset.") return true } private func didUMVolatileMemoryDecrease(in snapshot: UMSnapshot) -> Bool { guard hasSeenUMSnapshot else { return false } guard let previousRecord = dataGroupRecords[0], let nextRecord = snapshot.dataGroupRecords[0] else { return false } return nextRecord.ah < (previousRecord.ah - volatileMemoryDecreaseEpsilon) || nextRecord.wh < (previousRecord.wh - volatileMemoryDecreaseEpsilon) } private func didUMDeviceReboot(with snapshot: UMSnapshot, at timestamp: Date) -> Bool { defer { hasSeenUMSnapshot = true } guard didUMVolatileMemoryDecrease(in: snapshot) else { return false } guard !shouldIgnoreVolatileMemoryDrop(at: timestamp) else { return false } track("\(name) - Inferred UM reboot because volatile memory dropped.") return true } private func didTC66DeviceReboot(with snapshot: TC66Snapshot) -> Bool { guard hasSeenTC66Snapshot else { return false } guard snapshot.bootCount != bootCount else { return false } track("\(name) - Inferred TC66 reboot because bootCount changed from \(bootCount) to \(snapshot.bootCount).") return true } private func updateChargerTypeLatch(observedIndex: UInt16, didDetectDeviceReset: Bool) { if didDetectDeviceReset, chargerTypeIndex != 0 { setIfChanged(\.chargerTypeIndex, to: 0) } guard supportsChargerDetection else { return } if chargerTypeIndex == 0 { setIfChanged(\.chargerTypeIndex, to: observedIndex) return } guard observedIndex != 0, observedIndex != chargerTypeIndex else { return } track("\(name) - Ignoring charger type change from \(chargerTypeIndex) to \(observedIndex) until the device reboots.") } func dataDumpRequest() { guard operationalState >= .peripheralReady else { track("\(name) - Skip data request while state is \(operationalState)") return } 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() scheduleDataDumpRequest(after: 1, reason: "queued command") } } /** 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)") liveDataChanged = false 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)") } } updateChargeRecord(at: dataDumpRequestTimestamp) measurements.addValues(timestamp: dataDumpRequestTimestamp, power: power, voltage: voltage, current: current) // DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) { // //track("\(name) - Scheduled new request.") // } if operationalState != .dataIsAvailable { operationalState = .dataIsAvailable } else if liveDataChanged { objectWillChange.send() } dataDumpRequest() } private func apply(umSnapshot snapshot: UMSnapshot) { let didDetectDeviceReset = didUMDeviceReboot(with: snapshot, at: dataDumpRequestTimestamp) setIfChanged(\.modelNumber, to: snapshot.modelNumber) setIfChanged(\.voltage, to: snapshot.voltage) setIfChanged(\.current, to: snapshot.current) setIfChanged(\.power, to: snapshot.power) setIfChanged(\.temperatureCelsius, to: snapshot.temperatureCelsius) setIfChanged(\.temperatureFahrenheit, to: snapshot.temperatureFahrenheit) setIfChanged(\.selectedDataGroup, to: snapshot.selectedDataGroup) for (index, record) in snapshot.dataGroupRecords { updateDataGroupRecord(index: index, ah: record.ah, wh: record.wh) } setIfChanged(\.usbPlusVoltage, to: snapshot.usbPlusVoltage) setIfChanged(\.usbMinusVoltage, to: snapshot.usbMinusVoltage) updateChargerTypeLatch(observedIndex: snapshot.chargerTypeIndex, didDetectDeviceReset: didDetectDeviceReset) setIfChanged(\.recordedAH, to: snapshot.recordedAH) setIfChanged(\.recordedWH, to: snapshot.recordedWH) if recordingThresholdTimestamp < dataDumpRequestTimestamp || !recordingThresholdLoadedFromDevice { recordingThresholdLoadedFromDevice = true if recordingTreshold != snapshot.recordingThreshold { isApplyingRecordingThresholdFromDevice = true recordingTreshold = snapshot.recordingThreshold isApplyingRecordingThresholdFromDevice = false } } else { track("\(name) - Skip updating recordingThreshold (changed after request).") } setIfChanged(\.recordingDuration, to: snapshot.recordingDuration) setIfChanged(\.recording, to: 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).") } setIfChanged(\.currentScreen, to: snapshot.currentScreen) setIfChanged(\.loadResistance, to: snapshot.loadResistance) } private func apply(tc66Snapshot snapshot: TC66Snapshot) { let didDetectDeviceReset = didTC66DeviceReboot(with: snapshot) if hasSeenTC66Snapshot { inferTC66ActiveDataGroup(from: snapshot) } else { hasSeenTC66Snapshot = true } setIfChanged(\.reportedModelName, to: snapshot.modelName) setIfChanged(\.firmwareVersion, to: snapshot.firmwareVersion) setIfChanged(\.serialNumber, to: snapshot.serialNumber) setIfChanged(\.bootCount, to: snapshot.bootCount) updateChargerTypeLatch(observedIndex: 0, didDetectDeviceReset: didDetectDeviceReset) setIfChanged(\.voltage, to: snapshot.voltage) setIfChanged(\.current, to: snapshot.current) setIfChanged(\.power, to: snapshot.power) setIfChanged(\.loadResistance, to: snapshot.loadResistance) for (index, record) in snapshot.dataGroupRecords { updateDataGroupRecord(index: index, ah: record.ah, wh: record.wh) } setIfChanged(\.temperatureCelsius, to: snapshot.temperatureCelsius) setIfChanged(\.usbPlusVoltage, to: snapshot.usbPlusVoltage) setIfChanged(\.usbMinusVoltage, to: snapshot.usbMinusVoltage) } private func inferTC66ActiveDataGroup(from snapshot: TC66Snapshot) { let candidate = snapshot.dataGroupRecords.compactMap { entry -> (UInt8, Double)? in let index = entry.key let record = entry.value guard let previous = dataGroupRecords[index] else { return nil } let deltaAH = max(record.ah - previous.ah, 0) let deltaWH = max(record.wh - previous.wh, 0) let score = deltaAH + deltaWH guard score > 0 else { return nil } return (UInt8(index), score) } .max { lhs, rhs in lhs.1 < rhs.1 } if let candidate { selectedDataGroup = candidate.0 hasObservedActiveDataGroup = true } } private func updateChargeRecord(at timestamp: Date) { switch chargeRecordState { case .waitingForStart: guard current > chargeRecordStopThreshold else { return } chargeRecordState = .active chargeRecordStartTimestamp = timestamp chargeRecordEndTimestamp = timestamp chargeRecordLastTimestamp = timestamp chargeRecordLastCurrent = current chargeRecordLastPower = power case .active: if let lastTimestamp = chargeRecordLastTimestamp { let deltaSeconds = max(timestamp.timeIntervalSince(lastTimestamp), 0) chargeRecordAH += chargeRecordLastCurrent * deltaSeconds / 3600 chargeRecordWH += chargeRecordLastPower * deltaSeconds / 3600 chargeRecordDuration += deltaSeconds } chargeRecordEndTimestamp = timestamp chargeRecordLastTimestamp = timestamp chargeRecordLastCurrent = current chargeRecordLastPower = power if current <= chargeRecordStopThreshold { chargeRecordState = .completed } case .completed: break } } func resetChargeRecord() { chargeRecordAH = 0 chargeRecordWH = 0 chargeRecordDuration = 0 chargeRecordState = .waitingForStart chargeRecordStartTimestamp = nil chargeRecordEndTimestamp = nil chargeRecordLastTimestamp = nil chargeRecordLastCurrent = 0 chargeRecordLastPower = 0 objectWillChange.send() } func resetChargeRecordGraph() { let cutoff = Date() resetChargeRecord() measurements.trim(before: cutoff) } 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 } noteInitiatedVolatileMemoryResetIfNeeded(for: selectedDataGroup) commandQueue.append(UMProtocol.clearCurrentGroup) } func clear(group id: UInt8) { guard supportsDataGroupCommands else { return } commandQueue.append(UMProtocol.selectDataGroup(id)) noteInitiatedVolatileMemoryResetIfNeeded(for: id) commandQueue.append(UMProtocol.clearCurrentGroup) commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup)) } func selectDataGroup ( id: UInt8) { guard supportsDataGroupCommands else { return } track("\(name) - \(id)") selectedDataGroup = id objectWillChange.send() 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) { let applyStateChange = { self.lastSeen = Date() switch serialPortOperationalState { case .peripheralNotConnected: self.operationalState = .peripheralNotConnected case .peripheralConnectionPending: self.operationalState = .peripheralConnectionPending case .peripheralConnected: self.operationalState = .peripheralConnected case .peripheralReady: self.operationalState = .peripheralReady } } if Thread.isMainThread { applyStateChange() } else { DispatchQueue.main.async(execute: applyStateChange) } } func didReceiveData(_ data: Data) { let applyData = { self.lastSeen = Date() if self.operationalState < .comunicating { self.operationalState = .comunicating } self.parseData(from: data) } if Thread.isMainThread { applyData() } else { DispatchQueue.main.async(execute: applyData) } } }