Newer Older
1273 lines | 46.481kb
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?
Bogdan Timofte authored a month ago
78
        let checkpointCount: Int
Bogdan Timofte authored a month ago
79
    }
80

            
Bogdan Timofte authored 2 months ago
81

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

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

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

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

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

            
161
    private var wdTimer: Timer?
162

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
756
        guard supportsChargerDetection else { return }
757

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1013
        if chargeRecordState != .active {
1014
            chargeRecordState = .active
1015
            didChange = true
1016
        }
1017

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

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

            
1030
        if chargeRecordStopThreshold != activeSession.stopThresholdAmps {
1031
            chargeRecordStopThreshold = activeSession.stopThresholdAmps
1032
            didChange = true
1033
        }
1034

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

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

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

            
1064
        if didChange {
1065
            objectWillChange.send()
1066
        }
1067
    }
1068

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

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

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

            
1108
        guard restoredChargeSessionID != activeSession.id else {
1109
            return
1110
        }
1111

            
1112
        restoredChargeSessionID = activeSession.id
Bogdan Timofte authored a month ago
1113

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

            
Bogdan Timofte authored a month ago
1119
        enableAutoConnect = true
1120

            
1121
        guard operationalState < .peripheralConnectionPending else {
1122
            return
1123
        }
1124

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

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

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

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

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

            
1168
    func resetMeterCountersForNewSession() {
1169
        guard supportsDataGroupCommands else { return }
1170

            
1171
        clear()
1172

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

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

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

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

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

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

            
1233
extension Meter : SerialPortDelegate {
1234

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

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

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

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