1 contributor
1036 lines | 36.665kb
//
//  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")
                handleMeasurementDiscontinuity(at: Date())
                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?

    @Published var lastSeen: Date? {
        didSet {
            wdTimer?.invalidate()
            guard lastSeen != nil else { return }
            appData.noteMeterSeen(at: lastSeen!, macAddress: btSerial.macAddress.description)
            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 preferredTabIdentifier: String = "home"

    @Published private(set) var lastConnectedAt: Date?
    
    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<String> = ["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<Date>? {
        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<T: Equatable>(_ keyPath: ReferenceWritableKeyPath<Meter, T>, 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
        lastSeen = appData.lastSeen(for: serialPort.macAddress.description)
        lastConnectedAt = appData.lastConnected(for: 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 noteConnectionEstablished(at date: Date) {
        lastConnectedAt = date
        appData.noteMeterConnected(at: date, macAddress: btSerial.macAddress.description)
    }

    private func handleMeasurementDiscontinuity(at timestamp: Date) {
        measurements.markDiscontinuity(at: timestamp)

        guard chargeRecordState == .active else { return }
        chargeRecordLastTimestamp = nil
        chargeRecordLastCurrent = 0
        chargeRecordLastPower = 0
    }

    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,
            temperature: displayedTemperatureValue,
            rssi: Double(btSerial.averageRSSI)
        )
//        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.noteConnectionEstablished(at: Date())
                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)
        }
    }
}