Newer Older
1275 lines | 46.424kb
Bogdan Timofte authored 2 months ago
1
//
2
//  File.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 03/03/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8
//MARK: Store and documentation: https://www.aliexpress.com/item/32968303350.html
9
//MARK: Protocol: https://sigrok.org/wiki/RDTech_UM_series
10
//MARK: Pithon Code: https://github.com/rfinnie/rdserialtool
11
//MARK: HM-10 Code: https://github.com/hoiberg/HM10-BluetoothSerial-iOS
12
//MARK: Package dependency https://github.com/krzyzanowskim/CryptoSwift
13

            
14
import CoreBluetooth
15
import SwiftUI
16

            
17
/**
18
 Supprted USB Meters
19
 # UM25C
20
 # TC66
21
 * Reverse Engineering
22
 [UM Series](https://sigrok.org/wiki/RDTech_UM_series)
23
 [TC66C](https://sigrok.org/wiki/RDTech_TC66C)
24
 */
Bogdan Timofte authored a month ago
25
enum Model: CaseIterable, Hashable {
Bogdan Timofte authored 2 months ago
26
    case UM25C
27
    case UM34C
28
    case TC66C
29
}
30

            
Bogdan Timofte authored 2 months ago
31
enum TemperatureUnitPreference: String, CaseIterable, Identifiable {
32
    case celsius
33
    case fahrenheit
34

            
35
    var id: String { rawValue }
36

            
37
    var title: String {
38
        switch self {
39
        case .celsius:
40
            return "Celsius"
41
        case .fahrenheit:
42
            return "Fahrenheit"
43
        }
44
    }
45

            
46
    var symbol: String {
47
        switch self {
48
        case .celsius:
49
            return "℃"
50
        case .fahrenheit:
51
            return "℉"
52
        }
53
    }
54
}
55

            
Bogdan Timofte authored 2 months ago
56
private extension TemperatureUnitPreference {
57
    var localeTitle: String {
58
        switch self {
59
        case .celsius:
60
            return "System (Celsius)"
61
        case .fahrenheit:
62
            return "System (Fahrenheit)"
63
        }
64
    }
65
}
66

            
Bogdan Timofte authored 2 months ago
67
enum ChargeRecordState {
68
    case waitingForStart
69
    case active
70
    case completed
71
}
72

            
Bogdan Timofte authored 2 months ago
73
class Meter : NSObject, ObservableObject, Identifiable {
Bogdan Timofte authored a month ago
74
    private struct ChargeRecordRestoreSignature: Equatable {
75
        let sessionID: UUID
76
        let sampleCount: Int
77
        let lastSampleTimestamp: Date?
78
    }
79

            
Bogdan Timofte authored 2 months ago
80

            
Bogdan Timofte authored 2 months ago
81
    private static func shouldLogOperationalStateTransition(from oldValue: OperationalState, to newValue: OperationalState) -> Bool {
82
        switch (oldValue, newValue) {
83
        case (.comunicating, .dataIsAvailable), (.dataIsAvailable, .comunicating):
84
            return false
85
        default:
86
            return true
87
        }
88
    }
89

            
Bogdan Timofte authored 2 months ago
90
    enum OperationalState: Int, Comparable {
91
        case notPresent
92
        case peripheralNotConnected
93
        case peripheralConnectionPending
94
        case peripheralConnected
95
        case peripheralReady
96
        case comunicating
97
        case dataIsAvailable
98

            
99
        static func < (lhs: OperationalState, rhs: OperationalState) -> Bool {
100
            return lhs.rawValue < rhs.rawValue
101
        }
102
    }
103

            
104
    @Published var operationalState = OperationalState.peripheralNotConnected {
105
        didSet {
Bogdan Timofte authored 2 months ago
106
            guard operationalState != oldValue else { return }
Bogdan Timofte authored 2 months ago
107
            if Self.shouldLogOperationalStateTransition(from: oldValue, to: operationalState) {
108
                track("\(name) - Operational state changed from \(oldValue) to \(operationalState)")
109
            }
Bogdan Timofte authored 2 months ago
110
            switch operationalState {
111
            case .notPresent:
Bogdan Timofte authored 2 months ago
112
                cancelPendingDataDumpRequest(reason: "meter missing")
Bogdan Timofte authored 2 months ago
113
                break
114
            case .peripheralNotConnected:
Bogdan Timofte authored 2 months ago
115
                cancelPendingDataDumpRequest(reason: "peripheral disconnected")
Bogdan Timofte authored 2 months ago
116
                handleMeasurementDiscontinuity(at: Date())
Bogdan Timofte authored 2 months ago
117
                if !commandQueue.isEmpty {
118
                    track("\(name) - Clearing \(commandQueue.count) queued commands after disconnect")
119
                    commandQueue.removeAll()
120
                }
Bogdan Timofte authored 2 months ago
121
                if enableAutoConnect {
122
                    track("\(name) - Reconnecting...")
123
                    btSerial.connect()
124
                }
125
            case .peripheralConnectionPending:
Bogdan Timofte authored 2 months ago
126
                cancelPendingDataDumpRequest(reason: "connection pending")
Bogdan Timofte authored 2 months ago
127
                break
128
            case .peripheralConnected:
Bogdan Timofte authored 2 months ago
129
                cancelPendingDataDumpRequest(reason: "services not ready yet")
Bogdan Timofte authored 2 months ago
130
                break
131
            case .peripheralReady:
Bogdan Timofte authored 2 months ago
132
                scheduleDataDumpRequest(after: 0.5, reason: "peripheral ready")
Bogdan Timofte authored 2 months ago
133
            case .comunicating:
134
                break
135
            case .dataIsAvailable:
136
                break
137
            }
138
        }
139
    }
140

            
141
    static func operationalColor(for state: OperationalState) -> Color {
142
        switch state {
143
        case .notPresent:
144
            return .red
145
        case .peripheralNotConnected:
146
            return .blue
147
        case .peripheralConnectionPending:
148
            return .yellow
149
        case .peripheralConnected:
150
            return .yellow
151
        case .peripheralReady:
152
            return .orange
153
        case .comunicating:
154
            return .orange
155
        case .dataIsAvailable:
156
            return .green
157
        }
158
    }
159

            
160
    private var wdTimer: Timer?
161

            
Bogdan Timofte authored 2 months ago
162
    @Published var lastSeen: Date? {
Bogdan Timofte authored 2 months ago
163
        didSet {
164
            wdTimer?.invalidate()
Bogdan Timofte authored 2 months ago
165
            guard lastSeen != nil else { return }
166
            appData.noteMeterSeen(at: lastSeen!, macAddress: btSerial.macAddress.description)
Bogdan Timofte authored 2 months ago
167
            if operationalState == .peripheralNotConnected {
168
                wdTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false, block: {_ in
169
                    track("\(self.name) - Lost advertisments...")
170
                    self.operationalState = .notPresent
171
                })
172
            } else if operationalState == .notPresent {
173
               operationalState = .peripheralNotConnected
174
            }
175
        }
176
    }
177

            
178
    var uuid: UUID
179
    var model: Model
180
    var modelString: String
181

            
Bogdan Timofte authored 2 months ago
182
    private var isSyncingNameFromStore = false
183

            
184
    @Published var name: String {
Bogdan Timofte authored 2 months ago
185
        didSet {
Bogdan Timofte authored 2 months ago
186
            guard !isSyncingNameFromStore else { return }
187
            guard oldValue != name else { return }
188
            appData.setMeterName(name, for: btSerial.macAddress.description)
Bogdan Timofte authored 2 months ago
189
        }
190
    }
Bogdan Timofte authored 2 months ago
191

            
Bogdan Timofte authored 2 months ago
192
    var preferredTabIdentifier: String = "home"
193

            
Bogdan Timofte authored 2 months ago
194
    @Published private(set) var lastConnectedAt: Date?
Bogdan Timofte authored 2 months ago
195

            
196
    var color : Color {
197
        get {
Bogdan Timofte authored 2 months ago
198
            return model.color
Bogdan Timofte authored 2 months ago
199
        }
200
    }
201

            
Bogdan Timofte authored 2 months ago
202
    var capabilities: MeterCapabilities {
203
        model.capabilities
204
    }
205

            
Bogdan Timofte authored 2 months ago
206
    var availableDataGroupIDs: [UInt8] {
Bogdan Timofte authored 2 months ago
207
        capabilities.availableDataGroupIDs
Bogdan Timofte authored 2 months ago
208
    }
209

            
210
    var supportsDataGroupCommands: Bool {
Bogdan Timofte authored 2 months ago
211
        capabilities.supportsDataGroupCommands
Bogdan Timofte authored 2 months ago
212
    }
213

            
Bogdan Timofte authored 2 months ago
214
    var supportsRecordingView: Bool {
215
        capabilities.supportsRecordingView
216
    }
217

            
Bogdan Timofte authored 2 months ago
218
    var supportsUMSettings: Bool {
Bogdan Timofte authored 2 months ago
219
        capabilities.supportsScreenSettings
Bogdan Timofte authored 2 months ago
220
    }
221

            
222
    var supportsRecordingThreshold: Bool {
Bogdan Timofte authored 2 months ago
223
        capabilities.supportsRecordingThreshold
Bogdan Timofte authored 2 months ago
224
    }
225

            
Bogdan Timofte authored 2 months ago
226
    var reportsCurrentScreenIndex: Bool {
227
        capabilities.reportsCurrentScreenIndex
228
    }
229

            
230
    var showsDataGroupEnergy: Bool {
231
        capabilities.showsDataGroupEnergy
232
    }
233

            
234
    var highlightsActiveDataGroup: Bool {
235
        if model == .TC66C {
236
            return hasObservedActiveDataGroup
237
        }
238
        return capabilities.highlightsActiveDataGroup
239
    }
240

            
Bogdan Timofte authored 2 months ago
241
    var supportsFahrenheit: Bool {
Bogdan Timofte authored 2 months ago
242
        capabilities.supportsFahrenheit
Bogdan Timofte authored 2 months ago
243
    }
244

            
Bogdan Timofte authored 2 months ago
245
    var supportsManualTemperatureUnitSelection: Bool {
246
        model == .TC66C
247
    }
248

            
Bogdan Timofte authored 2 months ago
249
    var supportsChargerDetection: Bool {
Bogdan Timofte authored 2 months ago
250
        capabilities.supportsChargerDetection
251
    }
252

            
Bogdan Timofte authored 2 months ago
253
    var dataGroupsTitle: String {
254
        capabilities.dataGroupsTitle
255
    }
256

            
Bogdan Timofte authored 2 months ago
257
    var documentedWorkingVoltage: String {
258
        capabilities.documentedWorkingVoltage
259
    }
260

            
Bogdan Timofte authored 2 months ago
261
    var chargerTypeDescription: String {
262
        capabilities.chargerTypeDescription(for: chargerTypeIndex)
Bogdan Timofte authored 2 months ago
263
    }
264

            
Bogdan Timofte authored 2 months ago
265
    var temperatureUnitDescription: String {
266
        if supportsManualTemperatureUnitSelection {
Bogdan Timofte authored 2 months ago
267
            return "Device-defined"
Bogdan Timofte authored 2 months ago
268
        }
Bogdan Timofte authored 2 months ago
269
        return systemTemperatureUnitPreference.localeTitle
Bogdan Timofte authored 2 months ago
270
    }
271

            
272
    var primaryTemperatureDescription: String {
Bogdan Timofte authored 2 months ago
273
        let value = displayedTemperatureValue.format(decimalDigits: 0)
Bogdan Timofte authored 2 months ago
274
        if supportsManualTemperatureUnitSelection {
Bogdan Timofte authored 2 months ago
275
            return "\(value)°"
Bogdan Timofte authored 2 months ago
276
        }
Bogdan Timofte authored 2 months ago
277
        return "\(value)\(systemTemperatureUnitPreference.symbol)"
Bogdan Timofte authored 2 months ago
278
    }
279

            
280
    var secondaryTemperatureDescription: String? {
Bogdan Timofte authored 2 months ago
281
        nil
282
    }
283

            
284
    var displayedTemperatureValue: Double {
285
        if supportsManualTemperatureUnitSelection {
286
            return temperatureCelsius
287
        }
288
        switch systemTemperatureUnitPreference {
289
        case .celsius:
290
            return displayedTemperatureCelsius
291
        case .fahrenheit:
292
            return displayedTemperatureFahrenheit
293
        }
294
    }
295

            
296
    private var displayedTemperatureCelsius: Double {
297
        if supportsManualTemperatureUnitSelection {
298
            switch tc66TemperatureUnitPreference {
299
            case .celsius:
300
                return temperatureCelsius
301
            case .fahrenheit:
302
                return (temperatureCelsius - 32) * 5 / 9
303
            }
304
        }
305
        return temperatureCelsius
306
    }
307

            
308
    private var displayedTemperatureFahrenheit: Double {
309
        if supportsManualTemperatureUnitSelection {
310
            switch tc66TemperatureUnitPreference {
311
            case .celsius:
312
                return (temperatureCelsius * 9 / 5) + 32
313
            case .fahrenheit:
314
                return temperatureCelsius
315
            }
316
        }
317
        if supportsFahrenheit, temperatureFahrenheit.isFinite {
318
            return temperatureFahrenheit
319
        }
320
        return (temperatureCelsius * 9 / 5) + 32
321
    }
322

            
323
    private var systemTemperatureUnitPreference: TemperatureUnitPreference {
324
        let locale = Locale.autoupdatingCurrent
325
        if #available(iOS 16.0, *) {
326
            switch locale.measurementSystem {
327
            case .us:
328
                return .fahrenheit
329
            default:
330
                return .celsius
331
            }
332
        }
333

            
334
        let regionCode = locale.regionCode ?? ""
335
        let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
336
        return fahrenheitRegions.contains(regionCode) ? .fahrenheit : .celsius
Bogdan Timofte authored 2 months ago
337
    }
338

            
Bogdan Timofte authored 2 months ago
339
    var currentScreenDescription: String {
Bogdan Timofte authored 2 months ago
340
        guard reportsCurrentScreenIndex else {
341
            return "Page Controls"
342
        }
Bogdan Timofte authored 2 months ago
343
        if let label = capabilities.screenDescription(for: currentScreen) {
344
            return "Screen \(currentScreen): \(label)"
345
        }
346
        return "Screen \(currentScreen)"
347
    }
348

            
Bogdan Timofte authored 2 months ago
349
    var deviceModelName: String {
Bogdan Timofte authored 2 months ago
350
        if !reportedModelName.isEmpty {
351
            return reportedModelName
352
        }
353
        return model.canonicalName
Bogdan Timofte authored 2 months ago
354
    }
355

            
Bogdan Timofte authored 2 months ago
356
    var deviceModelSummary: String {
Bogdan Timofte authored 2 months ago
357
        let baseName = deviceModelName
Bogdan Timofte authored 2 months ago
358
        if modelNumber != 0 {
359
            return "\(baseName) (\(modelNumber))"
360
        }
361
        return baseName
362
    }
363

            
Bogdan Timofte authored 2 months ago
364
    var recordingDurationDescription: String {
365
        let totalSeconds = Int(recordingDuration)
366
        let hours = totalSeconds / 3600
367
        let minutes = (totalSeconds % 3600) / 60
368
        let seconds = totalSeconds % 60
369

            
370
        if hours > 0 {
371
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
372
        }
373
        return String(format: "%02d:%02d", minutes, seconds)
374
    }
375

            
Bogdan Timofte authored 2 months ago
376
    var chargeRecordDurationDescription: String {
377
        let totalSeconds = Int(chargeRecordDuration)
378
        let hours = totalSeconds / 3600
379
        let minutes = (totalSeconds % 3600) / 60
380
        let seconds = totalSeconds % 60
381

            
382
        if hours > 0 {
383
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
384
        }
385
        return String(format: "%02d:%02d", minutes, seconds)
386
    }
387

            
388
    var chargeRecordTimeRange: ClosedRange<Date>? {
389
        guard let start = chargeRecordStartTimestamp else { return nil }
390
        let end = chargeRecordEndTimestamp ?? chargeRecordLastTimestamp
391
        guard let end else { return nil }
392
        return start...end
393
    }
394

            
395
    var chargeRecordStatusText: String {
396
        switch chargeRecordState {
397
        case .waitingForStart:
398
            return "Waiting"
399
        case .active:
400
            return "Active"
401
        case .completed:
402
            return "Completed"
403
        }
404
    }
405

            
406
    var chargeRecordStatusColor: Color {
407
        switch chargeRecordState {
408
        case .waitingForStart:
409
            return .secondary
410
        case .active:
411
            return .red
412
        case .completed:
413
            return .green
414
        }
415
    }
416

            
Bogdan Timofte authored 2 months ago
417
    var dataGroupsHint: String? {
Bogdan Timofte authored 2 months ago
418
        if model == .TC66C {
419
            if hasObservedActiveDataGroup {
420
                return "The active memory is inferred from the totals that are currently increasing."
421
            }
422
            return "The device exposes two read-only memories. The active memory is inferred once one total starts increasing."
423
        }
424
        return capabilities.dataGroupsHint
425
    }
426

            
427
    func dataGroupLabel(for id: UInt8) -> String {
428
        supportsDataGroupCommands ? "\(id)" : "M\(Int(id) + 1)"
Bogdan Timofte authored 2 months ago
429
    }
430

            
431
    var recordingThresholdHint: String? {
432
        capabilities.recordingThresholdHint
433
    }
434

            
Bogdan Timofte authored 2 months ago
435
    var btSerial: BluetoothSerial
Bogdan Timofte authored 2 months ago
436

            
Bogdan Timofte authored a month ago
437
    let measurements = Measurements()
438
    let chargeRecordMeasurements = Measurements()
Bogdan Timofte authored 2 months ago
439

            
Bogdan Timofte authored a month ago
440
    private let minimumLivePollingInterval: TimeInterval = 0.4
Bogdan Timofte authored 2 months ago
441
    private var commandQueue: [Data] = []
442
    private var dataDumpRequestTimestamp = Date()
Bogdan Timofte authored 2 months ago
443
    private var pendingDataDumpWorkItem: DispatchWorkItem?
Bogdan Timofte authored 2 months ago
444

            
445
    class DataGroupRecord {
Bogdan Timofte authored 2 months ago
446
        var ah: Double
447
        var wh: Double
Bogdan Timofte authored 2 months ago
448
        init(ah: Double, wh: Double) {
449
            self.ah = ah
450
            self.wh = wh
451
        }
452
    }
Bogdan Timofte authored 2 months ago
453
    private(set) var selectedDataGroup: UInt8 = 0
454
    private(set) var dataGroupRecords: [Int : DataGroupRecord] = [:]
455
    private(set) var chargeRecordAH: Double = 0
456
    private(set) var chargeRecordWH: Double = 0
457
    private(set) var chargeRecordDuration: TimeInterval = 0
Bogdan Timofte authored 2 months ago
458
    @Published var chargeRecordStopThreshold: Double = 0.05
459
    @Published var tc66TemperatureUnitPreference: TemperatureUnitPreference = .celsius {
460
        didSet {
461
            guard supportsManualTemperatureUnitSelection else { return }
462
            guard oldValue != tc66TemperatureUnitPreference else { return }
Bogdan Timofte authored 2 months ago
463
            appData.setTemperatureUnitPreference(tc66TemperatureUnitPreference, for: btSerial.macAddress.description)
Bogdan Timofte authored 2 months ago
464
        }
465
    }
Bogdan Timofte authored 2 months ago
466

            
467
    @Published var screenBrightness: Int = -1 {
468
        didSet {
469
            if oldValue != screenBrightness {
470
                screenBrightnessTimestamp = Date()
471
                if oldValue != -1 {
472
                    setSceeenBrightness(to: UInt8(screenBrightness))
473
                }
474
            }
475
        }
476
    }
477
    private var screenBrightnessTimestamp = Date()
478

            
479
    @Published var screenTimeout: Int = -1 {
480
        didSet {
481
            if oldValue != screenTimeout {
482
                screenTimeoutTimestamp = Date()
483
                if oldValue != -1 {
484
                    setScreenSaverTimeout(to: UInt8(screenTimeout))
485
                }
486
            }
487
        }
488
    }
489
    private var screenTimeoutTimestamp = Date()
490

            
Bogdan Timofte authored 2 months ago
491
    private(set) var voltage: Double = 0
492
    private(set) var current: Double = 0
493
    private(set) var power: Double = 0
494
    private(set) var temperatureCelsius: Double = 0
495
    private(set) var temperatureFahrenheit: Double = 0
496
    private(set) var usbPlusVoltage: Double = 0
497
    private(set) var usbMinusVoltage: Double = 0
498
    private(set) var recordedAH: Double = 0
499
    private(set) var recordedWH: Double = 0
500
    private(set) var recording: Bool = false
Bogdan Timofte authored 2 months ago
501
    @Published var recordingTreshold: Double = 0 {
Bogdan Timofte authored 2 months ago
502
        didSet {
Bogdan Timofte authored 2 months ago
503
            guard recordingTreshold != oldValue else { return }
504
            if isApplyingRecordingThresholdFromDevice {
505
                return
Bogdan Timofte authored 2 months ago
506
            }
Bogdan Timofte authored 2 months ago
507
            recordingThresholdTimestamp = Date()
508
            guard recordingThresholdLoadedFromDevice else { return }
509
            setrecordingTreshold(to: (recordingTreshold * 100).uInt8Value)
Bogdan Timofte authored 2 months ago
510
        }
Bogdan Timofte authored 2 months ago
511
    }
Bogdan Timofte authored 2 months ago
512
    private(set) var currentScreen: UInt16 = 0
513
    private(set) var recordingDuration: UInt32 = 0
514
    private(set) var loadResistance: Double = 0
515
    private(set) var modelNumber: UInt16 = 0
516
    private(set) var chargerTypeIndex: UInt16 = 0
517
    private(set) var reportedModelName: String = ""
518
    private(set) var firmwareVersion: String = ""
519
    private(set) var serialNumber: UInt32 = 0
520
    private(set) var bootCount: UInt32 = 0
Bogdan Timofte authored 2 months ago
521
    private var enableAutoConnect: Bool = false
Bogdan Timofte authored 2 months ago
522
    private var recordingThresholdTimestamp = Date()
523
    private var recordingThresholdLoadedFromDevice = false
524
    private var isApplyingRecordingThresholdFromDevice = false
Bogdan Timofte authored 2 months ago
525
    private(set) var chargeRecordState = ChargeRecordState.waitingForStart
Bogdan Timofte authored 2 months ago
526
    private var chargeRecordStartTimestamp: Date?
527
    private var chargeRecordEndTimestamp: Date?
528
    private var chargeRecordLastTimestamp: Date?
529
    private var chargeRecordLastCurrent: Double = 0
530
    private var chargeRecordLastPower: Double = 0
Bogdan Timofte authored 2 months ago
531
    private let volatileMemoryDecreaseEpsilon = 0.0005
532
    private let initiatedVolatileMemoryResetGraceWindow: TimeInterval = 12
533
    private var hasSeenUMSnapshot = false
Bogdan Timofte authored 2 months ago
534
    private var hasObservedActiveDataGroup = false
535
    private var hasSeenTC66Snapshot = false
Bogdan Timofte authored 2 months ago
536
    private var pendingVolatileMemoryResetIgnoreCount = 0
537
    private var pendingVolatileMemoryResetDeadline: Date?
Bogdan Timofte authored 2 months ago
538
    private var liveDataChanged = false
Bogdan Timofte authored a month ago
539
    private var restoredChargeSessionID: UUID?
Bogdan Timofte authored a month ago
540
    private var restoredChargeRecordSignature: ChargeRecordRestoreSignature?
Bogdan Timofte authored a month ago
541
    private var lastRecorderObservationAt: Date?
Bogdan Timofte authored 2 months ago
542

            
Bogdan Timofte authored 2 months ago
543
    @discardableResult
544
    private func setIfChanged<T: Equatable>(_ keyPath: ReferenceWritableKeyPath<Meter, T>, to value: T) -> Bool {
545
        guard self[keyPath: keyPath] != value else { return false }
546
        self[keyPath: keyPath] = value
547
        liveDataChanged = true
548
        return true
549
    }
550

            
551
    private func updateDataGroupRecord(index: Int, ah: Double, wh: Double) {
552
        if let existing = dataGroupRecords[index] {
553
            if existing.ah != ah { existing.ah = ah; liveDataChanged = true }
554
            if existing.wh != wh { existing.wh = wh; liveDataChanged = true }
555
        } else {
556
            dataGroupRecords[index] = DataGroupRecord(ah: ah, wh: wh)
557
            liveDataChanged = true
558
        }
559
    }
560

            
Bogdan Timofte authored 2 months ago
561
    init ( model: Model, with serialPort: BluetoothSerial ) {
562
        uuid = serialPort.peripheral.identifier
563
        //dataStore.meterUUIDS.append(serialPort.peripheral.identifier)
564
        modelString = serialPort.peripheral.name!
565
        self.model = model
566
        btSerial = serialPort
Bogdan Timofte authored 2 months ago
567
        name = appData.meterName(for: serialPort.macAddress.description) ?? serialPort.macAddress.description
Bogdan Timofte authored 2 months ago
568
        lastSeen = appData.lastSeen(for: serialPort.macAddress.description)
569
        lastConnectedAt = appData.lastConnected(for: serialPort.macAddress.description)
Bogdan Timofte authored 2 months ago
570
        super.init()
571
        btSerial.delegate = self
Bogdan Timofte authored 2 months ago
572
        reloadTemperatureUnitPreference()
Bogdan Timofte authored 2 months ago
573
        //name = dataStore.meterNames[macAddress.description] ?? peripheral.name!
574
        for index in stride(from: 0, through: 9, by: 1) {
575
            dataGroupRecords[index] = DataGroupRecord(ah:0, wh: 0)
576
        }
577
    }
Bogdan Timofte authored 2 months ago
578

            
579
    func reloadTemperatureUnitPreference() {
580
        guard supportsManualTemperatureUnitSelection else { return }
Bogdan Timofte authored 2 months ago
581
        let persistedPreference = appData.temperatureUnitPreference(for: btSerial.macAddress.description)
Bogdan Timofte authored 2 months ago
582
        if tc66TemperatureUnitPreference != persistedPreference {
583
            tc66TemperatureUnitPreference = persistedPreference
584
        }
585
    }
Bogdan Timofte authored 2 months ago
586

            
Bogdan Timofte authored 2 months ago
587
    func updateNameFromStore(_ newName: String) {
588
        guard newName != name else { return }
589
        isSyncingNameFromStore = true
590
        name = newName
591
        isSyncingNameFromStore = false
592
    }
593

            
Bogdan Timofte authored 2 months ago
594
    private func noteConnectionEstablished(at date: Date) {
595
        lastConnectedAt = date
596
        appData.noteMeterConnected(at: date, macAddress: btSerial.macAddress.description)
597
    }
598

            
Bogdan Timofte authored 2 months ago
599
    private func handleMeasurementDiscontinuity(at timestamp: Date) {
600
        measurements.markDiscontinuity(at: timestamp)
Bogdan Timofte authored a month ago
601
        chargeRecordMeasurements.markDiscontinuity(at: timestamp)
Bogdan Timofte authored 2 months ago
602

            
603
        guard chargeRecordState == .active else { return }
604
        chargeRecordLastTimestamp = nil
605
        chargeRecordLastCurrent = 0
606
        chargeRecordLastPower = 0
607
    }
608

            
Bogdan Timofte authored 2 months ago
609
    private func currentEnergySample() -> (groupID: UInt8, value: Double)? {
610
        guard showsDataGroupEnergy else { return nil }
611

            
612
        if model == .TC66C && !hasObservedActiveDataGroup {
613
            return nil
614
        }
615

            
616
        let groupID = selectedDataGroup
617
        guard let record = dataGroupRecords[Int(groupID)] else { return nil }
618
        return (groupID, record.wh)
619
    }
620

            
Bogdan Timofte authored a month ago
621
    private func currentChargeSample() -> (groupID: UInt8, value: Double)? {
622
        guard showsDataGroupEnergy else { return nil }
623

            
624
        if model == .TC66C && !hasObservedActiveDataGroup {
625
            return nil
626
        }
627

            
628
        let groupID = selectedDataGroup
629
        guard let record = dataGroupRecords[Int(groupID)] else { return nil }
630
        return (groupID, record.ah)
631
    }
632

            
633
    func chargingMonitorSnapshot(at observedAt: Date) -> ChargingMonitorSnapshot? {
Bogdan Timofte authored a month ago
634
        let usesNativeRecordingCounters = supportsRecordingView
635
        let nativeChargeCounter = usesNativeRecordingCounters ? recordedAH : nil
636
        let nativeEnergyCounter = usesNativeRecordingCounters ? recordedWH : nil
637

            
638
        return ChargingMonitorSnapshot(
Bogdan Timofte authored a month ago
639
            meterMACAddress: btSerial.macAddress.description,
640
            meterName: name,
641
            meterModel: deviceModelSummary,
642
            observedAt: observedAt,
643
            voltageVolts: voltage,
644
            currentAmps: current,
645
            powerWatts: power,
Bogdan Timofte authored a month ago
646
            selectedDataGroup: usesNativeRecordingCounters ? nil : (currentEnergySample()?.groupID ?? currentChargeSample()?.groupID),
647
            meterChargeCounterAh: nativeChargeCounter ?? currentChargeSample()?.value,
648
            meterEnergyCounterWh: nativeEnergyCounter ?? currentEnergySample()?.value,
Bogdan Timofte authored a month ago
649
            meterRecordingDurationSeconds: usesNativeRecordingCounters ? TimeInterval(recordingDuration) : nil,
Bogdan Timofte authored a month ago
650
            fallbackStopThresholdAmps: supportsRecordingThreshold ? recordingTreshold : chargeRecordStopThreshold
Bogdan Timofte authored a month ago
651
        )
652
    }
653

            
Bogdan Timofte authored a month ago
654
    var recordingBootedAt: Date? {
655
        guard supportsRecordingView else { return nil }
656
        guard let lastRecorderObservationAt else { return nil }
657
        return lastRecorderObservationAt.addingTimeInterval(-TimeInterval(recordingDuration))
658
    }
659

            
Bogdan Timofte authored a month ago
660
    var chargingMonitorSnapshot: ChargingMonitorSnapshot? {
661
        chargingMonitorSnapshot(at: Date())
662
    }
663

            
Bogdan Timofte authored 2 months ago
664
    private func cancelPendingDataDumpRequest(reason: String) {
665
        guard let pendingDataDumpWorkItem else { return }
666
        track("\(name) - Cancel scheduled data request (\(reason))")
667
        pendingDataDumpWorkItem.cancel()
668
        self.pendingDataDumpWorkItem = nil
669
    }
670

            
671
    private func scheduleDataDumpRequest(after delay: TimeInterval, reason: String) {
672
        cancelPendingDataDumpRequest(reason: "reschedule")
673

            
674
        let workItem = DispatchWorkItem { [weak self] in
675
            guard let self else { return }
676
            self.pendingDataDumpWorkItem = nil
677
            self.dataDumpRequest()
678
        }
679
        pendingDataDumpWorkItem = workItem
680
        track("\(name) - Schedule data request in \(String(format: "%.2f", delay))s (\(reason))")
681
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
682
    }
Bogdan Timofte authored 2 months ago
683

            
Bogdan Timofte authored a month ago
684
    private func scheduleNextLiveDataDumpRequest() {
685
        let elapsedSinceLastRequest = Date().timeIntervalSince(dataDumpRequestTimestamp)
686
        let delay = max(minimumLivePollingInterval - elapsedSinceLastRequest, 0)
687
        scheduleDataDumpRequest(after: delay, reason: "continuous live polling")
688
    }
689

            
Bogdan Timofte authored 2 months ago
690
    private func noteInitiatedVolatileMemoryResetIfNeeded(for groupID: UInt8) {
691
        guard groupID == 0 else { return }
692
        pendingVolatileMemoryResetIgnoreCount += 1
693
        pendingVolatileMemoryResetDeadline = Date().addingTimeInterval(initiatedVolatileMemoryResetGraceWindow)
694
        track("\(name) - Will ignore the next volatile memory drop caused by an app reset request.")
695
    }
696

            
697
    private func pendingInitiatedVolatileMemoryResetIsActive(at timestamp: Date) -> Bool {
698
        guard let pendingVolatileMemoryResetDeadline else { return false }
699
        guard pendingVolatileMemoryResetIgnoreCount > 0 else {
700
            self.pendingVolatileMemoryResetDeadline = nil
701
            return false
702
        }
703
        guard timestamp <= pendingVolatileMemoryResetDeadline else {
704
            track("\(name) - Expiring stale volatile memory reset ignore state.")
705
            pendingVolatileMemoryResetIgnoreCount = 0
706
            self.pendingVolatileMemoryResetDeadline = nil
707
            return false
708
        }
709
        return true
710
    }
711

            
712
    private func shouldIgnoreVolatileMemoryDrop(at timestamp: Date) -> Bool {
713
        guard pendingInitiatedVolatileMemoryResetIsActive(at: timestamp) else { return false }
714
        pendingVolatileMemoryResetIgnoreCount -= 1
715
        if pendingVolatileMemoryResetIgnoreCount == 0 {
716
            pendingVolatileMemoryResetDeadline = nil
717
        }
718
        track("\(name) - Ignoring volatile memory drop after an app-initiated reset.")
719
        return true
720
    }
721

            
722
    private func didUMVolatileMemoryDecrease(in snapshot: UMSnapshot) -> Bool {
723
        guard hasSeenUMSnapshot else { return false }
724
        guard let previousRecord = dataGroupRecords[0], let nextRecord = snapshot.dataGroupRecords[0] else {
725
            return false
726
        }
727

            
728
        return nextRecord.ah < (previousRecord.ah - volatileMemoryDecreaseEpsilon)
729
            || nextRecord.wh < (previousRecord.wh - volatileMemoryDecreaseEpsilon)
730
    }
731

            
732
    private func didUMDeviceReboot(with snapshot: UMSnapshot, at timestamp: Date) -> Bool {
733
        defer { hasSeenUMSnapshot = true }
734

            
735
        guard didUMVolatileMemoryDecrease(in: snapshot) else { return false }
736
        guard !shouldIgnoreVolatileMemoryDrop(at: timestamp) else { return false }
737

            
738
        track("\(name) - Inferred UM reboot because volatile memory dropped.")
739
        return true
740
    }
741

            
742
    private func didTC66DeviceReboot(with snapshot: TC66Snapshot) -> Bool {
743
        guard hasSeenTC66Snapshot else { return false }
744
        guard snapshot.bootCount != bootCount else { return false }
745

            
746
        track("\(name) - Inferred TC66 reboot because bootCount changed from \(bootCount) to \(snapshot.bootCount).")
747
        return true
748
    }
749

            
750
    private func updateChargerTypeLatch(observedIndex: UInt16, didDetectDeviceReset: Bool) {
751
        if didDetectDeviceReset, chargerTypeIndex != 0 {
Bogdan Timofte authored 2 months ago
752
            setIfChanged(\.chargerTypeIndex, to: 0)
Bogdan Timofte authored 2 months ago
753
        }
754

            
755
        guard supportsChargerDetection else { return }
756

            
757
        if chargerTypeIndex == 0 {
Bogdan Timofte authored 2 months ago
758
            setIfChanged(\.chargerTypeIndex, to: observedIndex)
Bogdan Timofte authored 2 months ago
759
            return
760
        }
761

            
762
        guard observedIndex != 0, observedIndex != chargerTypeIndex else { return }
763
        track("\(name) - Ignoring charger type change from \(chargerTypeIndex) to \(observedIndex) until the device reboots.")
764
    }
Bogdan Timofte authored 2 months ago
765

            
766
    func dataDumpRequest() {
Bogdan Timofte authored 2 months ago
767
        guard operationalState >= .peripheralReady else {
768
            track("\(name) - Skip data request while state is \(operationalState)")
769
            return
770
        }
Bogdan Timofte authored 2 months ago
771
        if commandQueue.isEmpty {
772
            switch model {
773
            case .UM25C:
Bogdan Timofte authored 2 months ago
774
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
Bogdan Timofte authored 2 months ago
775
            case .UM34C:
Bogdan Timofte authored 2 months ago
776
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
Bogdan Timofte authored 2 months ago
777
            case .TC66C:
Bogdan Timofte authored 2 months ago
778
                btSerial.write(TC66Protocol.snapshotRequest, expectedResponseLength: 192)
Bogdan Timofte authored 2 months ago
779
            }
780
            dataDumpRequestTimestamp = Date()
781
            // track("\(name) - Request sent!")
782
        } else {
783
            track("Request delayed for: \(commandQueue.first!.hexEncodedStringValue)")
784
            btSerial.write( commandQueue.first! )
785
            commandQueue.removeFirst()
Bogdan Timofte authored 2 months ago
786
            scheduleDataDumpRequest(after: 1, reason: "queued command")
Bogdan Timofte authored 2 months ago
787
        }
788
    }
789

            
790
    /**
791
     received data parser
792
     - parameter buffer cotains response for data dump request
793
     - Decription metod for TC66C AES ECB response  found [here](https:github.com/krzyzanowskim/CryptoSwift/issues/693)
794
     */
795
    func parseData ( from buffer: Data) {
796
        //track("\(name)")
Bogdan Timofte authored 2 months ago
797
        liveDataChanged = false
Bogdan Timofte authored 2 months ago
798
        switch model {
799
        case .UM25C:
Bogdan Timofte authored 2 months ago
800
            do {
801
                apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
802
            } catch {
803
                track("\(name) - Error: \(error)")
804
            }
Bogdan Timofte authored 2 months ago
805
        case .UM34C:
Bogdan Timofte authored 2 months ago
806
            do {
807
                apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
808
            } catch {
809
                track("\(name) - Error: \(error)")
810
            }
Bogdan Timofte authored 2 months ago
811
        case .TC66C:
Bogdan Timofte authored 2 months ago
812
            do {
813
                apply(tc66Snapshot: try TC66Protocol.parseSnapshot(from: buffer))
814
            } catch {
815
                track("\(name) - Error: \(error)")
816
            }
Bogdan Timofte authored 2 months ago
817
        }
Bogdan Timofte authored 2 months ago
818
        updateChargeRecord(at: dataDumpRequestTimestamp)
Bogdan Timofte authored a month ago
819
        captureLiveMeasurements(at: dataDumpRequestTimestamp, in: measurements)
820
        if chargeRecordState != .waitingForStart {
Bogdan Timofte authored a month ago
821
            captureLiveMeasurements(
822
                at: dataDumpRequestTimestamp,
823
                in: chargeRecordMeasurements,
824
                includesTemperature: false
825
            )
Bogdan Timofte authored 2 months ago
826
        }
Bogdan Timofte authored a month ago
827
        appData.observeChargeSnapshot(from: self, observedAt: dataDumpRequestTimestamp)
Bogdan Timofte authored 2 months ago
828
//        DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
829
//            //track("\(name) - Scheduled new request.")
830
//        }
Bogdan Timofte authored 2 months ago
831
        if operationalState != .dataIsAvailable {
832
            operationalState = .dataIsAvailable
833
        } else if liveDataChanged {
834
            objectWillChange.send()
835
        }
Bogdan Timofte authored a month ago
836
        scheduleNextLiveDataDumpRequest()
Bogdan Timofte authored 2 months ago
837
    }
838

            
Bogdan Timofte authored 2 months ago
839
    private func apply(umSnapshot snapshot: UMSnapshot) {
Bogdan Timofte authored 2 months ago
840
        let didDetectDeviceReset = didUMDeviceReboot(with: snapshot, at: dataDumpRequestTimestamp)
Bogdan Timofte authored a month ago
841
        lastRecorderObservationAt = dataDumpRequestTimestamp
Bogdan Timofte authored 2 months ago
842
        setIfChanged(\.modelNumber, to: snapshot.modelNumber)
843
        setIfChanged(\.voltage, to: snapshot.voltage)
844
        setIfChanged(\.current, to: snapshot.current)
845
        setIfChanged(\.power, to: snapshot.power)
846
        setIfChanged(\.temperatureCelsius, to: snapshot.temperatureCelsius)
847
        setIfChanged(\.temperatureFahrenheit, to: snapshot.temperatureFahrenheit)
848
        setIfChanged(\.selectedDataGroup, to: snapshot.selectedDataGroup)
Bogdan Timofte authored 2 months ago
849
        for (index, record) in snapshot.dataGroupRecords {
Bogdan Timofte authored 2 months ago
850
            updateDataGroupRecord(index: index, ah: record.ah, wh: record.wh)
Bogdan Timofte authored 2 months ago
851
        }
Bogdan Timofte authored 2 months ago
852
        setIfChanged(\.usbPlusVoltage, to: snapshot.usbPlusVoltage)
853
        setIfChanged(\.usbMinusVoltage, to: snapshot.usbMinusVoltage)
Bogdan Timofte authored 2 months ago
854
        updateChargerTypeLatch(observedIndex: snapshot.chargerTypeIndex, didDetectDeviceReset: didDetectDeviceReset)
Bogdan Timofte authored 2 months ago
855
        setIfChanged(\.recordedAH, to: snapshot.recordedAH)
856
        setIfChanged(\.recordedWH, to: snapshot.recordedWH)
Bogdan Timofte authored 2 months ago
857

            
858
        if recordingThresholdTimestamp < dataDumpRequestTimestamp || !recordingThresholdLoadedFromDevice {
859
            recordingThresholdLoadedFromDevice = true
860
            if recordingTreshold != snapshot.recordingThreshold {
861
                isApplyingRecordingThresholdFromDevice = true
862
                recordingTreshold = snapshot.recordingThreshold
863
                isApplyingRecordingThresholdFromDevice = false
864
            }
865
        } else {
866
            track("\(name) - Skip updating recordingThreshold (changed after request).")
867
        }
Bogdan Timofte authored 2 months ago
868
        setIfChanged(\.recordingDuration, to: snapshot.recordingDuration)
869
        setIfChanged(\.recording, to: snapshot.recording)
Bogdan Timofte authored 2 months ago
870

            
Bogdan Timofte authored 2 months ago
871
        if screenTimeoutTimestamp < dataDumpRequestTimestamp {
Bogdan Timofte authored 2 months ago
872
            if screenTimeout != snapshot.screenTimeout {
873
                screenTimeout = snapshot.screenTimeout
Bogdan Timofte authored 2 months ago
874
            }
875
        } else {
876
            track("\(name) - Skip updating screenTimeout (changed after request).")
877
        }
878

            
879
        if screenBrightnessTimestamp < dataDumpRequestTimestamp {
Bogdan Timofte authored 2 months ago
880
            if screenBrightness != snapshot.screenBrightness {
881
                screenBrightness = snapshot.screenBrightness
Bogdan Timofte authored 2 months ago
882
            }
883
        } else {
884
            track("\(name) - Skip updating screenBrightness (changed after request).")
885
        }
886

            
Bogdan Timofte authored 2 months ago
887
        setIfChanged(\.currentScreen, to: snapshot.currentScreen)
888
        setIfChanged(\.loadResistance, to: snapshot.loadResistance)
Bogdan Timofte authored 2 months ago
889
    }
890

            
Bogdan Timofte authored 2 months ago
891
    private func apply(tc66Snapshot snapshot: TC66Snapshot) {
Bogdan Timofte authored 2 months ago
892
        let didDetectDeviceReset = didTC66DeviceReboot(with: snapshot)
Bogdan Timofte authored 2 months ago
893
        if hasSeenTC66Snapshot {
894
            inferTC66ActiveDataGroup(from: snapshot)
895
        } else {
896
            hasSeenTC66Snapshot = true
897
        }
Bogdan Timofte authored 2 months ago
898
        setIfChanged(\.reportedModelName, to: snapshot.modelName)
899
        setIfChanged(\.firmwareVersion, to: snapshot.firmwareVersion)
900
        setIfChanged(\.serialNumber, to: snapshot.serialNumber)
901
        setIfChanged(\.bootCount, to: snapshot.bootCount)
Bogdan Timofte authored 2 months ago
902
        updateChargerTypeLatch(observedIndex: 0, didDetectDeviceReset: didDetectDeviceReset)
Bogdan Timofte authored 2 months ago
903
        setIfChanged(\.voltage, to: snapshot.voltage)
904
        setIfChanged(\.current, to: snapshot.current)
905
        setIfChanged(\.power, to: snapshot.power)
906
        setIfChanged(\.loadResistance, to: snapshot.loadResistance)
Bogdan Timofte authored 2 months ago
907
        for (index, record) in snapshot.dataGroupRecords {
Bogdan Timofte authored 2 months ago
908
            updateDataGroupRecord(index: index, ah: record.ah, wh: record.wh)
Bogdan Timofte authored 2 months ago
909
        }
Bogdan Timofte authored 2 months ago
910
        setIfChanged(\.temperatureCelsius, to: snapshot.temperatureCelsius)
911
        setIfChanged(\.usbPlusVoltage, to: snapshot.usbPlusVoltage)
912
        setIfChanged(\.usbMinusVoltage, to: snapshot.usbMinusVoltage)
Bogdan Timofte authored 2 months ago
913
    }
Bogdan Timofte authored 2 months ago
914

            
915
    private func inferTC66ActiveDataGroup(from snapshot: TC66Snapshot) {
916
        let candidate = snapshot.dataGroupRecords.compactMap { entry -> (UInt8, Double)? in
917
            let index = entry.key
918
            let record = entry.value
919
            guard let previous = dataGroupRecords[index] else { return nil }
920
            let deltaAH = max(record.ah - previous.ah, 0)
921
            let deltaWH = max(record.wh - previous.wh, 0)
922
            let score = deltaAH + deltaWH
923
            guard score > 0 else { return nil }
924
            return (UInt8(index), score)
925
        }
926
        .max { lhs, rhs in lhs.1 < rhs.1 }
927

            
928
        if let candidate {
929
            selectedDataGroup = candidate.0
930
            hasObservedActiveDataGroup = true
931
        }
932
    }
933

            
934
    private func updateChargeRecord(at timestamp: Date) {
935
        switch chargeRecordState {
936
        case .waitingForStart:
937
            guard current > chargeRecordStopThreshold else { return }
938
            chargeRecordState = .active
939
            chargeRecordStartTimestamp = timestamp
940
            chargeRecordEndTimestamp = timestamp
941
            chargeRecordLastTimestamp = timestamp
942
            chargeRecordLastCurrent = current
943
            chargeRecordLastPower = power
944
        case .active:
945
            if let lastTimestamp = chargeRecordLastTimestamp {
946
                let deltaSeconds = max(timestamp.timeIntervalSince(lastTimestamp), 0)
947
                chargeRecordAH += chargeRecordLastCurrent * deltaSeconds / 3600
948
                chargeRecordWH += chargeRecordLastPower * deltaSeconds / 3600
949
                chargeRecordDuration += deltaSeconds
950
            }
951
            chargeRecordEndTimestamp = timestamp
952
            chargeRecordLastTimestamp = timestamp
953
            chargeRecordLastCurrent = current
954
            chargeRecordLastPower = power
955
            if current <= chargeRecordStopThreshold {
956
                chargeRecordState = .completed
957
            }
958
        case .completed:
959
            break
960
        }
961
    }
962

            
963
    func resetChargeRecord() {
964
        chargeRecordAH = 0
965
        chargeRecordWH = 0
966
        chargeRecordDuration = 0
967
        chargeRecordState = .waitingForStart
968
        chargeRecordStartTimestamp = nil
969
        chargeRecordEndTimestamp = nil
970
        chargeRecordLastTimestamp = nil
971
        chargeRecordLastCurrent = 0
972
        chargeRecordLastPower = 0
Bogdan Timofte authored a month ago
973
        restoredChargeSessionID = nil
974
        restoredChargeRecordSignature = nil
975
        chargeRecordMeasurements.resetSeries()
Bogdan Timofte authored 2 months ago
976
        objectWillChange.send()
Bogdan Timofte authored 2 months ago
977
    }
978

            
979
    func resetChargeRecordGraph() {
980
        resetChargeRecord()
981
    }
Bogdan Timofte authored a month ago
982

            
Bogdan Timofte authored a month ago
983
    func restoreChargeRecordIfNeeded(
984
        from activeSession: ChargeSessionSummary,
985
        replacingLiveBufferIfNeeded: Bool = false
986
    ) {
Bogdan Timofte authored a month ago
987
        var didChange = false
988
        let restoreSignature = ChargeRecordRestoreSignature(
989
            sessionID: activeSession.id,
990
            sampleCount: activeSession.aggregatedSamples.count,
991
            lastSampleTimestamp: activeSession.aggregatedSamples.last?.timestamp
992
        )
993

            
994
        if restoreSignature != restoredChargeRecordSignature {
Bogdan Timofte authored a month ago
995
            restoreTrace("meter=\(name) charge-record-restore-start session=\(activeSession.id.uuidString) status=\(activeSession.status.rawValue) samples=\(restoreSignature.sampleCount) lastTs=\(restoreSignature.lastSampleTimestamp?.description ?? "nil") replaceLive=\(replacingLiveBufferIfNeeded) state=\(chargeRecordState) existingPower=\(chargeRecordMeasurements.power.samplePoints.count)")
996
            let didRestorePersistedSamples = chargeRecordMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
997
                from: activeSession,
998
                replacingLiveBufferIfNeeded: replacingLiveBufferIfNeeded
Bogdan Timofte authored a month ago
999
            )
Bogdan Timofte authored a month ago
1000
            restoreTrace("meter=\(name) charge-record-restore-result session=\(activeSession.id.uuidString) didRestore=\(didRestorePersistedSamples) priorSignatureSamples=\(restoredChargeRecordSignature?.sampleCount.description ?? "nil")")
1001
            if didRestorePersistedSamples || activeSession.aggregatedSamples.isEmpty == false {
Bogdan Timofte authored a month ago
1002
                restoredChargeRecordSignature = restoreSignature
Bogdan Timofte authored a month ago
1003
            }
1004
            if didRestorePersistedSamples {
Bogdan Timofte authored a month ago
1005
                didChange = true
1006
            }
1007
        }
1008

            
1009
        if chargeRecordState != .active {
1010
            chargeRecordState = .active
1011
            didChange = true
1012
        }
1013

            
1014
        let resolvedChargeAH = max(chargeRecordAH, activeSession.measuredChargeAh)
1015
        if resolvedChargeAH != chargeRecordAH {
1016
            chargeRecordAH = resolvedChargeAH
1017
            didChange = true
1018
        }
1019

            
1020
        let resolvedChargeWH = max(chargeRecordWH, activeSession.measuredEnergyWh)
1021
        if resolvedChargeWH != chargeRecordWH {
1022
            chargeRecordWH = resolvedChargeWH
1023
            didChange = true
1024
        }
1025

            
1026
        let resolvedDuration = max(chargeRecordDuration, max(activeSession.effectiveDuration, 0))
1027
        if resolvedDuration != chargeRecordDuration {
1028
            chargeRecordDuration = resolvedDuration
1029
            didChange = true
1030
        }
1031

            
1032
        if chargeRecordStopThreshold != activeSession.stopThresholdAmps {
1033
            chargeRecordStopThreshold = activeSession.stopThresholdAmps
1034
            didChange = true
1035
        }
1036

            
1037
        if let chargeRecordStartTimestamp {
1038
            let restoredStart = min(chargeRecordStartTimestamp, activeSession.startedAt)
1039
            if restoredStart != chargeRecordStartTimestamp {
1040
                self.chargeRecordStartTimestamp = restoredStart
1041
                didChange = true
1042
            }
1043
        } else {
1044
            chargeRecordStartTimestamp = activeSession.startedAt
1045
            didChange = true
1046
        }
1047

            
1048
        if let chargeRecordEndTimestamp {
1049
            let restoredEnd = max(chargeRecordEndTimestamp, activeSession.lastObservedAt)
1050
            if restoredEnd != chargeRecordEndTimestamp {
1051
                self.chargeRecordEndTimestamp = restoredEnd
1052
                didChange = true
1053
            }
1054
        } else {
1055
            chargeRecordEndTimestamp = activeSession.lastObservedAt
1056
            didChange = true
1057
        }
1058

            
Bogdan Timofte authored a month ago
1059
        if let selectedDataGroup = activeSession.selectedDataGroup {
Bogdan Timofte authored a month ago
1060
            if self.selectedDataGroup != selectedDataGroup {
1061
                self.selectedDataGroup = selectedDataGroup
1062
                didChange = true
1063
            }
Bogdan Timofte authored a month ago
1064
        }
Bogdan Timofte authored a month ago
1065

            
1066
        if didChange {
1067
            objectWillChange.send()
1068
        }
1069
    }
1070

            
Bogdan Timofte authored a month ago
1071
    private func captureLiveMeasurements(
1072
        at timestamp: Date,
1073
        in destination: Measurements,
1074
        includesTemperature: Bool = true
1075
    ) {
Bogdan Timofte authored a month ago
1076
        if supportsRecordingView {
1077
            destination.captureEnergyValue(
1078
                timestamp: timestamp,
1079
                value: recordedWH,
1080
                groupID: .max
1081
            )
1082
        } else if let energySample = currentEnergySample() {
1083
            destination.captureEnergyValue(
1084
                timestamp: timestamp,
1085
                value: energySample.value,
1086
                groupID: energySample.groupID
1087
            )
1088
        }
1089

            
1090
        destination.addValues(
1091
            timestamp: timestamp,
1092
            power: power,
1093
            voltage: voltage,
1094
            current: current,
Bogdan Timofte authored a month ago
1095
            temperature: includesTemperature ? displayedTemperatureValue : nil,
Bogdan Timofte authored a month ago
1096
            rssi: Double(btSerial.averageRSSI)
1097
        )
Bogdan Timofte authored a month ago
1098
    }
1099

            
1100
    func restoreChargeMonitoringIfNeeded(from activeSession: ChargeSessionSummary) {
Bogdan Timofte authored a month ago
1101
        let shouldReplaceLiveBuffer = restoredChargeSessionID != activeSession.id
1102
        if shouldReplaceLiveBuffer {
1103
            restoreTrace("meter=\(name) charge-monitoring-restore-request session=\(activeSession.id.uuidString) status=\(activeSession.status.rawValue) replaceLive=\(shouldReplaceLiveBuffer) restoredSession=\(restoredChargeSessionID?.uuidString ?? "nil")")
1104
        }
1105
        restoreChargeRecordIfNeeded(
1106
            from: activeSession,
1107
            replacingLiveBufferIfNeeded: shouldReplaceLiveBuffer
1108
        )
Bogdan Timofte authored a month ago
1109

            
1110
        guard restoredChargeSessionID != activeSession.id else {
1111
            return
1112
        }
1113

            
1114
        restoredChargeSessionID = activeSession.id
Bogdan Timofte authored a month ago
1115

            
1116
        guard activeSession.status == .active else {
1117
            restoreTrace("meter=\(name) charge-monitoring-restore-no-reconnect session=\(activeSession.id.uuidString) status=\(activeSession.status.rawValue)")
1118
            return
1119
        }
1120

            
Bogdan Timofte authored a month ago
1121
        enableAutoConnect = true
1122

            
1123
        guard operationalState < .peripheralConnectionPending else {
1124
            return
1125
        }
1126

            
1127
        track("\(name) - Restoring active charge session and reconnecting to meter")
1128
        btSerial.connect()
1129
    }
Bogdan Timofte authored 2 months ago
1130

            
1131
    func nextScreen() {
1132
        switch model {
1133
        case .UM25C:
Bogdan Timofte authored 2 months ago
1134
            commandQueue.append(UMProtocol.nextScreen)
Bogdan Timofte authored 2 months ago
1135
        case .UM34C:
Bogdan Timofte authored 2 months ago
1136
            commandQueue.append(UMProtocol.nextScreen)
Bogdan Timofte authored 2 months ago
1137
        case .TC66C:
Bogdan Timofte authored 2 months ago
1138
            commandQueue.append(TC66Protocol.nextPage)
Bogdan Timofte authored 2 months ago
1139
        }
1140
    }
1141

            
1142
    func rotateScreen() {
1143
        switch model {
1144
        case .UM25C:
Bogdan Timofte authored 2 months ago
1145
            commandQueue.append(UMProtocol.rotateScreen)
Bogdan Timofte authored 2 months ago
1146
        case .UM34C:
Bogdan Timofte authored 2 months ago
1147
            commandQueue.append(UMProtocol.rotateScreen)
Bogdan Timofte authored 2 months ago
1148
        case .TC66C:
Bogdan Timofte authored 2 months ago
1149
            commandQueue.append(TC66Protocol.rotateScreen)
Bogdan Timofte authored 2 months ago
1150
        }
1151
    }
1152

            
1153
    func previousScreen() {
1154
        switch model {
1155
        case .UM25C:
Bogdan Timofte authored 2 months ago
1156
            commandQueue.append(UMProtocol.previousScreen)
Bogdan Timofte authored 2 months ago
1157
        case .UM34C:
Bogdan Timofte authored 2 months ago
1158
            commandQueue.append(UMProtocol.previousScreen)
Bogdan Timofte authored 2 months ago
1159
        case .TC66C:
Bogdan Timofte authored 2 months ago
1160
            commandQueue.append(TC66Protocol.previousPage)
Bogdan Timofte authored 2 months ago
1161
        }
1162
    }
1163

            
1164
    func clear() {
Bogdan Timofte authored 2 months ago
1165
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 months ago
1166
        noteInitiatedVolatileMemoryResetIfNeeded(for: selectedDataGroup)
Bogdan Timofte authored 2 months ago
1167
        commandQueue.append(UMProtocol.clearCurrentGroup)
Bogdan Timofte authored 2 months ago
1168
    }
Bogdan Timofte authored a month ago
1169

            
1170
    func resetMeterCountersForNewSession() {
1171
        guard supportsDataGroupCommands else { return }
1172

            
1173
        clear()
1174

            
1175
        if let record = dataGroupRecords[Int(selectedDataGroup)] {
1176
            record.ah = 0
1177
            record.wh = 0
1178
        }
1179
        recordedAH = 0
1180
        recordedWH = 0
1181
        recording = false
1182
        objectWillChange.send()
1183
    }
Bogdan Timofte authored 2 months ago
1184

            
1185
    func clear(group id: UInt8) {
Bogdan Timofte authored 2 months ago
1186
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 months ago
1187
        commandQueue.append(UMProtocol.selectDataGroup(id))
Bogdan Timofte authored 2 months ago
1188
        noteInitiatedVolatileMemoryResetIfNeeded(for: id)
1189
        commandQueue.append(UMProtocol.clearCurrentGroup)
Bogdan Timofte authored 2 months ago
1190
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
Bogdan Timofte authored 2 months ago
1191
    }
1192

            
1193
    func selectDataGroup ( id: UInt8) {
Bogdan Timofte authored 2 months ago
1194
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 months ago
1195
        track("\(name) - \(id)")
1196
        selectedDataGroup = id
Bogdan Timofte authored 2 months ago
1197
        objectWillChange.send()
Bogdan Timofte authored 2 months ago
1198
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
Bogdan Timofte authored 2 months ago
1199
    }
1200

            
1201
    private func setSceeenBrightness ( to value: UInt8) {
1202
        track("\(name) - \(value)")
Bogdan Timofte authored 2 months ago
1203
        guard supportsUMSettings else { return }
Bogdan Timofte authored 2 months ago
1204
        commandQueue.append(UMProtocol.setScreenBrightness(value))
Bogdan Timofte authored 2 months ago
1205
    }
1206
    private func setScreenSaverTimeout ( to value: UInt8) {
1207
        track("\(name) - \(value)")
Bogdan Timofte authored 2 months ago
1208
        guard supportsUMSettings else { return }
Bogdan Timofte authored 2 months ago
1209
        commandQueue.append(UMProtocol.setScreenSaverTimeout(value))
Bogdan Timofte authored 2 months ago
1210
    }
1211
    func setrecordingTreshold ( to value: UInt8) {
Bogdan Timofte authored 2 months ago
1212
        guard supportsRecordingThreshold else { return }
Bogdan Timofte authored 2 months ago
1213
        commandQueue.append(UMProtocol.setRecordingThreshold(value))
Bogdan Timofte authored 2 months ago
1214
    }
1215

            
1216
    /**
1217
     Connect to meter.
1218
     1. It calls BluetoothSerial.connect
1219
     */
1220
    func connect() {
1221
        enableAutoConnect = true
1222
        btSerial.connect()
1223
    }
1224

            
1225
    /**
1226
     Disconnect from meter.
1227
        It calls BluetoothSerial.disconnect
1228
     */
1229
    func disconnect() {
1230
        enableAutoConnect = false
1231
        btSerial.disconnect()
1232
    }
1233
}
1234

            
1235
extension Meter : SerialPortDelegate {
1236

            
1237
    func opertionalStateChanged(to serialPortOperationalState: BluetoothSerial.OperationalState) {
Bogdan Timofte authored 2 months ago
1238
        let applyStateChange = {
1239
            self.lastSeen = Date()
1240
            switch serialPortOperationalState {
1241
            case .peripheralNotConnected:
1242
                self.operationalState = .peripheralNotConnected
1243
            case .peripheralConnectionPending:
1244
                self.operationalState = .peripheralConnectionPending
1245
            case .peripheralConnected:
Bogdan Timofte authored 2 months ago
1246
                self.noteConnectionEstablished(at: Date())
Bogdan Timofte authored 2 months ago
1247
                self.operationalState = .peripheralConnected
1248
            case .peripheralReady:
1249
                self.operationalState = .peripheralReady
1250
            }
1251
        }
1252

            
1253
        if Thread.isMainThread {
1254
            applyStateChange()
1255
        } else {
1256
            DispatchQueue.main.async(execute: applyStateChange)
Bogdan Timofte authored 2 months ago
1257
        }
1258
    }
1259

            
1260
    func didReceiveData(_ data: Data) {
Bogdan Timofte authored 2 months ago
1261
        let applyData = {
1262
            self.lastSeen = Date()
Bogdan Timofte authored 2 months ago
1263
            if self.operationalState < .comunicating {
1264
                self.operationalState = .comunicating
1265
            }
Bogdan Timofte authored 2 months ago
1266
            self.parseData(from: data)
1267
        }
1268

            
1269
        if Thread.isMainThread {
1270
            applyData()
1271
        } else {
1272
            DispatchQueue.main.async(execute: applyData)
1273
        }
Bogdan Timofte authored 2 months ago
1274
    }
1275
}