Newer Older
969 lines | 33.726kb
Bogdan Timofte authored 2 weeks 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 2 weeks ago
25
enum Model: CaseIterable {
Bogdan Timofte authored 2 weeks ago
26
    case UM25C
27
    case UM34C
28
    case TC66C
29
}
30

            
Bogdan Timofte authored 2 weeks 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 weeks 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 weeks ago
67
enum ChargeRecordState {
68
    case waitingForStart
69
    case active
70
    case completed
71
}
72

            
Bogdan Timofte authored 2 weeks ago
73
class Meter : NSObject, ObservableObject, Identifiable {
74

            
Bogdan Timofte authored 2 weeks ago
75
    private static func shouldLogOperationalStateTransition(from oldValue: OperationalState, to newValue: OperationalState) -> Bool {
76
        switch (oldValue, newValue) {
77
        case (.comunicating, .dataIsAvailable), (.dataIsAvailable, .comunicating):
78
            return false
79
        default:
80
            return true
81
        }
82
    }
83

            
Bogdan Timofte authored 2 weeks ago
84
    enum OperationalState: Int, Comparable {
85
        case notPresent
86
        case peripheralNotConnected
87
        case peripheralConnectionPending
88
        case peripheralConnected
89
        case peripheralReady
90
        case comunicating
91
        case dataIsAvailable
92

            
93
        static func < (lhs: OperationalState, rhs: OperationalState) -> Bool {
94
            return lhs.rawValue < rhs.rawValue
95
        }
96
    }
97

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

            
134
    static func operationalColor(for state: OperationalState) -> Color {
135
        switch state {
136
        case .notPresent:
137
            return .red
138
        case .peripheralNotConnected:
139
            return .blue
140
        case .peripheralConnectionPending:
141
            return .yellow
142
        case .peripheralConnected:
143
            return .yellow
144
        case .peripheralReady:
145
            return .orange
146
        case .comunicating:
147
            return .orange
148
        case .dataIsAvailable:
149
            return .green
150
        }
151
    }
152

            
153
    private var wdTimer: Timer?
154

            
155
    @Published var lastSeen = Date() {
156
        didSet {
157
            wdTimer?.invalidate()
158
            if operationalState == .peripheralNotConnected {
159
                wdTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false, block: {_ in
160
                    track("\(self.name) - Lost advertisments...")
161
                    self.operationalState = .notPresent
162
                })
163
            } else if operationalState == .notPresent {
164
               operationalState = .peripheralNotConnected
165
            }
166
        }
167
    }
168

            
169
    var uuid: UUID
170
    var model: Model
171
    var modelString: String
172

            
173
    var name: String {
174
        didSet {
175
            appData.meterNames[btSerial.macAddress.description] = name
176
        }
177
    }
178

            
179
    var color : Color {
180
        get {
Bogdan Timofte authored 2 weeks ago
181
            return model.color
Bogdan Timofte authored 2 weeks ago
182
        }
183
    }
184

            
Bogdan Timofte authored 2 weeks ago
185
    var capabilities: MeterCapabilities {
186
        model.capabilities
187
    }
188

            
Bogdan Timofte authored 2 weeks ago
189
    var availableDataGroupIDs: [UInt8] {
Bogdan Timofte authored 2 weeks ago
190
        capabilities.availableDataGroupIDs
Bogdan Timofte authored 2 weeks ago
191
    }
192

            
193
    var supportsDataGroupCommands: Bool {
Bogdan Timofte authored 2 weeks ago
194
        capabilities.supportsDataGroupCommands
Bogdan Timofte authored 2 weeks ago
195
    }
196

            
Bogdan Timofte authored 2 weeks ago
197
    var supportsRecordingView: Bool {
198
        capabilities.supportsRecordingView
199
    }
200

            
Bogdan Timofte authored 2 weeks ago
201
    var supportsUMSettings: Bool {
Bogdan Timofte authored 2 weeks ago
202
        capabilities.supportsScreenSettings
Bogdan Timofte authored 2 weeks ago
203
    }
204

            
205
    var supportsRecordingThreshold: Bool {
Bogdan Timofte authored 2 weeks ago
206
        capabilities.supportsRecordingThreshold
Bogdan Timofte authored 2 weeks ago
207
    }
208

            
Bogdan Timofte authored 2 weeks ago
209
    var reportsCurrentScreenIndex: Bool {
210
        capabilities.reportsCurrentScreenIndex
211
    }
212

            
213
    var showsDataGroupEnergy: Bool {
214
        capabilities.showsDataGroupEnergy
215
    }
216

            
217
    var highlightsActiveDataGroup: Bool {
218
        if model == .TC66C {
219
            return hasObservedActiveDataGroup
220
        }
221
        return capabilities.highlightsActiveDataGroup
222
    }
223

            
Bogdan Timofte authored 2 weeks ago
224
    var supportsFahrenheit: Bool {
Bogdan Timofte authored 2 weeks ago
225
        capabilities.supportsFahrenheit
Bogdan Timofte authored 2 weeks ago
226
    }
227

            
Bogdan Timofte authored 2 weeks ago
228
    var supportsManualTemperatureUnitSelection: Bool {
229
        model == .TC66C
230
    }
231

            
Bogdan Timofte authored 2 weeks ago
232
    var supportsChargerDetection: Bool {
Bogdan Timofte authored 2 weeks ago
233
        capabilities.supportsChargerDetection
234
    }
235

            
Bogdan Timofte authored 2 weeks ago
236
    var dataGroupsTitle: String {
237
        capabilities.dataGroupsTitle
238
    }
239

            
Bogdan Timofte authored 2 weeks ago
240
    var documentedWorkingVoltage: String {
241
        capabilities.documentedWorkingVoltage
242
    }
243

            
Bogdan Timofte authored 2 weeks ago
244
    var chargerTypeDescription: String {
245
        capabilities.chargerTypeDescription(for: chargerTypeIndex)
Bogdan Timofte authored 2 weeks ago
246
    }
247

            
Bogdan Timofte authored 2 weeks ago
248
    var temperatureUnitDescription: String {
249
        if supportsManualTemperatureUnitSelection {
Bogdan Timofte authored 2 weeks ago
250
            return "Device-defined"
Bogdan Timofte authored 2 weeks ago
251
        }
Bogdan Timofte authored 2 weeks ago
252
        return systemTemperatureUnitPreference.localeTitle
Bogdan Timofte authored 2 weeks ago
253
    }
254

            
255
    var primaryTemperatureDescription: String {
Bogdan Timofte authored 2 weeks ago
256
        let value = displayedTemperatureValue.format(decimalDigits: 0)
Bogdan Timofte authored 2 weeks ago
257
        if supportsManualTemperatureUnitSelection {
Bogdan Timofte authored 2 weeks ago
258
            return "\(value)°"
Bogdan Timofte authored 2 weeks ago
259
        }
Bogdan Timofte authored 2 weeks ago
260
        return "\(value)\(systemTemperatureUnitPreference.symbol)"
Bogdan Timofte authored 2 weeks ago
261
    }
262

            
263
    var secondaryTemperatureDescription: String? {
Bogdan Timofte authored 2 weeks ago
264
        nil
265
    }
266

            
267
    var displayedTemperatureValue: Double {
268
        if supportsManualTemperatureUnitSelection {
269
            return temperatureCelsius
270
        }
271
        switch systemTemperatureUnitPreference {
272
        case .celsius:
273
            return displayedTemperatureCelsius
274
        case .fahrenheit:
275
            return displayedTemperatureFahrenheit
276
        }
277
    }
278

            
279
    private var displayedTemperatureCelsius: Double {
280
        if supportsManualTemperatureUnitSelection {
281
            switch tc66TemperatureUnitPreference {
282
            case .celsius:
283
                return temperatureCelsius
284
            case .fahrenheit:
285
                return (temperatureCelsius - 32) * 5 / 9
286
            }
287
        }
288
        return temperatureCelsius
289
    }
290

            
291
    private var displayedTemperatureFahrenheit: Double {
292
        if supportsManualTemperatureUnitSelection {
293
            switch tc66TemperatureUnitPreference {
294
            case .celsius:
295
                return (temperatureCelsius * 9 / 5) + 32
296
            case .fahrenheit:
297
                return temperatureCelsius
298
            }
299
        }
300
        if supportsFahrenheit, temperatureFahrenheit.isFinite {
301
            return temperatureFahrenheit
302
        }
303
        return (temperatureCelsius * 9 / 5) + 32
304
    }
305

            
306
    private var systemTemperatureUnitPreference: TemperatureUnitPreference {
307
        let locale = Locale.autoupdatingCurrent
308
        if #available(iOS 16.0, *) {
309
            switch locale.measurementSystem {
310
            case .us:
311
                return .fahrenheit
312
            default:
313
                return .celsius
314
            }
315
        }
316

            
317
        let regionCode = locale.regionCode ?? ""
318
        let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
319
        return fahrenheitRegions.contains(regionCode) ? .fahrenheit : .celsius
Bogdan Timofte authored 2 weeks ago
320
    }
321

            
Bogdan Timofte authored 2 weeks ago
322
    var currentScreenDescription: String {
Bogdan Timofte authored 2 weeks ago
323
        guard reportsCurrentScreenIndex else {
324
            return "Page Controls"
325
        }
Bogdan Timofte authored 2 weeks ago
326
        if let label = capabilities.screenDescription(for: currentScreen) {
327
            return "Screen \(currentScreen): \(label)"
328
        }
329
        return "Screen \(currentScreen)"
330
    }
331

            
Bogdan Timofte authored 2 weeks ago
332
    var deviceModelName: String {
Bogdan Timofte authored 2 weeks ago
333
        if !reportedModelName.isEmpty {
334
            return reportedModelName
335
        }
336
        return model.canonicalName
Bogdan Timofte authored 2 weeks ago
337
    }
338

            
Bogdan Timofte authored 2 weeks ago
339
    var deviceModelSummary: String {
Bogdan Timofte authored 2 weeks ago
340
        let baseName = deviceModelName
Bogdan Timofte authored 2 weeks ago
341
        if modelNumber != 0 {
342
            return "\(baseName) (\(modelNumber))"
343
        }
344
        return baseName
345
    }
346

            
Bogdan Timofte authored 2 weeks ago
347
    var recordingDurationDescription: String {
348
        let totalSeconds = Int(recordingDuration)
349
        let hours = totalSeconds / 3600
350
        let minutes = (totalSeconds % 3600) / 60
351
        let seconds = totalSeconds % 60
352

            
353
        if hours > 0 {
354
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
355
        }
356
        return String(format: "%02d:%02d", minutes, seconds)
357
    }
358

            
Bogdan Timofte authored 2 weeks ago
359
    var chargeRecordDurationDescription: String {
360
        let totalSeconds = Int(chargeRecordDuration)
361
        let hours = totalSeconds / 3600
362
        let minutes = (totalSeconds % 3600) / 60
363
        let seconds = totalSeconds % 60
364

            
365
        if hours > 0 {
366
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
367
        }
368
        return String(format: "%02d:%02d", minutes, seconds)
369
    }
370

            
371
    var chargeRecordTimeRange: ClosedRange<Date>? {
372
        guard let start = chargeRecordStartTimestamp else { return nil }
373
        let end = chargeRecordEndTimestamp ?? chargeRecordLastTimestamp
374
        guard let end else { return nil }
375
        return start...end
376
    }
377

            
378
    var chargeRecordStatusText: String {
379
        switch chargeRecordState {
380
        case .waitingForStart:
381
            return "Waiting"
382
        case .active:
383
            return "Active"
384
        case .completed:
385
            return "Completed"
386
        }
387
    }
388

            
389
    var chargeRecordStatusColor: Color {
390
        switch chargeRecordState {
391
        case .waitingForStart:
392
            return .secondary
393
        case .active:
394
            return .red
395
        case .completed:
396
            return .green
397
        }
398
    }
399

            
Bogdan Timofte authored 2 weeks ago
400
    var dataGroupsHint: String? {
Bogdan Timofte authored 2 weeks ago
401
        if model == .TC66C {
402
            if hasObservedActiveDataGroup {
403
                return "The active memory is inferred from the totals that are currently increasing."
404
            }
405
            return "The device exposes two read-only memories. The active memory is inferred once one total starts increasing."
406
        }
407
        return capabilities.dataGroupsHint
408
    }
409

            
410
    func dataGroupLabel(for id: UInt8) -> String {
411
        supportsDataGroupCommands ? "\(id)" : "M\(Int(id) + 1)"
Bogdan Timofte authored 2 weeks ago
412
    }
413

            
414
    var recordingThresholdHint: String? {
415
        capabilities.recordingThresholdHint
416
    }
417

            
Bogdan Timofte authored 2 weeks ago
418
    @Published var btSerial: BluetoothSerial
419

            
420
    @Published var measurements = Measurements()
421

            
422
    private var commandQueue: [Data] = []
423
    private var dataDumpRequestTimestamp = Date()
Bogdan Timofte authored 2 weeks ago
424
    private var pendingDataDumpWorkItem: DispatchWorkItem?
Bogdan Timofte authored 2 weeks ago
425

            
426
    class DataGroupRecord {
427
        @Published var ah: Double
428
        @Published var wh: Double
429
        init(ah: Double, wh: Double) {
430
            self.ah = ah
431
            self.wh = wh
432
        }
433
    }
434
    @Published var selectedDataGroup: UInt8 = 0
435
    @Published var dataGroupRecords: [Int : DataGroupRecord] = [:]
Bogdan Timofte authored 2 weeks ago
436
    @Published var chargeRecordAH: Double = 0
437
    @Published var chargeRecordWH: Double = 0
438
    @Published var chargeRecordDuration: TimeInterval = 0
439
    @Published var chargeRecordStopThreshold: Double = 0.05
440
    @Published var tc66TemperatureUnitPreference: TemperatureUnitPreference = .celsius {
441
        didSet {
442
            guard supportsManualTemperatureUnitSelection else { return }
443
            guard oldValue != tc66TemperatureUnitPreference else { return }
444
            var settings = appData.tc66TemperatureUnits
445
            settings[btSerial.macAddress.description] = tc66TemperatureUnitPreference.rawValue
446
            appData.tc66TemperatureUnits = settings
447
        }
448
    }
Bogdan Timofte authored 2 weeks ago
449

            
450
    @Published var screenBrightness: Int = -1 {
451
        didSet {
452
            if oldValue != screenBrightness {
453
                screenBrightnessTimestamp = Date()
454
                if oldValue != -1 {
455
                    setSceeenBrightness(to: UInt8(screenBrightness))
456
                }
457
            }
458
        }
459
    }
460
    private var screenBrightnessTimestamp = Date()
461

            
462
    @Published var screenTimeout: Int = -1 {
463
        didSet {
464
            if oldValue != screenTimeout {
465
                screenTimeoutTimestamp = Date()
466
                if oldValue != -1 {
467
                    setScreenSaverTimeout(to: UInt8(screenTimeout))
468
                }
469
            }
470
        }
471
    }
472
    private var screenTimeoutTimestamp = Date()
473

            
474
    @Published var voltage: Double = 0
475
    @Published var current: Double = 0
476
    @Published var power: Double = 0
477
    @Published var temperatureCelsius: Double = 0
478
    @Published var temperatureFahrenheit: Double = 0
479
    @Published var usbPlusVoltage: Double = 0
480
    @Published var usbMinusVoltage: Double = 0
481
    @Published var recordedAH: Double = 0
482
    @Published var recordedWH: Double = 0
483
    @Published var recording: Bool = false
Bogdan Timofte authored 2 weeks ago
484
    @Published var recordingTreshold: Double = 0 {
Bogdan Timofte authored 2 weeks ago
485
        didSet {
Bogdan Timofte authored 2 weeks ago
486
            guard recordingTreshold != oldValue else { return }
487
            if isApplyingRecordingThresholdFromDevice {
488
                return
Bogdan Timofte authored 2 weeks ago
489
            }
Bogdan Timofte authored 2 weeks ago
490
            recordingThresholdTimestamp = Date()
491
            guard recordingThresholdLoadedFromDevice else { return }
492
            setrecordingTreshold(to: (recordingTreshold * 100).uInt8Value)
Bogdan Timofte authored 2 weeks ago
493
        }
Bogdan Timofte authored 2 weeks ago
494
    }
Bogdan Timofte authored 2 weeks ago
495
    @Published var currentScreen: UInt16 = 0
496
    @Published var recordingDuration: UInt32 = 0
497
    @Published var loadResistance: Double = 0
498
    @Published var modelNumber: UInt16 = 0
499
    @Published var chargerTypeIndex: UInt16 = 0
Bogdan Timofte authored 2 weeks ago
500
    @Published var reportedModelName: String = ""
501
    @Published var firmwareVersion: String = ""
502
    @Published var serialNumber: UInt32 = 0
503
    @Published var bootCount: UInt32 = 0
Bogdan Timofte authored 2 weeks ago
504
    private var enableAutoConnect: Bool = false
Bogdan Timofte authored 2 weeks ago
505
    private var recordingThresholdTimestamp = Date()
506
    private var recordingThresholdLoadedFromDevice = false
507
    private var isApplyingRecordingThresholdFromDevice = false
Bogdan Timofte authored 2 weeks ago
508
    @Published private(set) var chargeRecordState = ChargeRecordState.waitingForStart
509
    private var chargeRecordStartTimestamp: Date?
510
    private var chargeRecordEndTimestamp: Date?
511
    private var chargeRecordLastTimestamp: Date?
512
    private var chargeRecordLastCurrent: Double = 0
513
    private var chargeRecordLastPower: Double = 0
Bogdan Timofte authored 2 weeks ago
514
    private let volatileMemoryDecreaseEpsilon = 0.0005
515
    private let initiatedVolatileMemoryResetGraceWindow: TimeInterval = 12
516
    private var hasSeenUMSnapshot = false
Bogdan Timofte authored 2 weeks ago
517
    private var hasObservedActiveDataGroup = false
518
    private var hasSeenTC66Snapshot = false
Bogdan Timofte authored 2 weeks ago
519
    private var pendingVolatileMemoryResetIgnoreCount = 0
520
    private var pendingVolatileMemoryResetDeadline: Date?
Bogdan Timofte authored 2 weeks ago
521

            
522
    init ( model: Model, with serialPort: BluetoothSerial ) {
523
        uuid = serialPort.peripheral.identifier
524
        //dataStore.meterUUIDS.append(serialPort.peripheral.identifier)
525
        modelString = serialPort.peripheral.name!
526
        self.model = model
527
        btSerial = serialPort
528
        name = appData.meterNames[serialPort.macAddress.description] ?? serialPort.macAddress.description
529
        super.init()
530
        btSerial.delegate = self
Bogdan Timofte authored 2 weeks ago
531
        reloadTemperatureUnitPreference()
Bogdan Timofte authored 2 weeks ago
532
        //name = dataStore.meterNames[macAddress.description] ?? peripheral.name!
533
        for index in stride(from: 0, through: 9, by: 1) {
534
            dataGroupRecords[index] = DataGroupRecord(ah:0, wh: 0)
535
        }
536
    }
Bogdan Timofte authored 2 weeks ago
537

            
538
    func reloadTemperatureUnitPreference() {
539
        guard supportsManualTemperatureUnitSelection else { return }
540
        let rawValue = appData.tc66TemperatureUnits[btSerial.macAddress.description] ?? TemperatureUnitPreference.celsius.rawValue
541
        let persistedPreference = TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
542
        if tc66TemperatureUnitPreference != persistedPreference {
543
            tc66TemperatureUnitPreference = persistedPreference
544
        }
545
    }
Bogdan Timofte authored 2 weeks ago
546

            
547
    private func cancelPendingDataDumpRequest(reason: String) {
548
        guard let pendingDataDumpWorkItem else { return }
549
        track("\(name) - Cancel scheduled data request (\(reason))")
550
        pendingDataDumpWorkItem.cancel()
551
        self.pendingDataDumpWorkItem = nil
552
    }
553

            
554
    private func scheduleDataDumpRequest(after delay: TimeInterval, reason: String) {
555
        cancelPendingDataDumpRequest(reason: "reschedule")
556

            
557
        let workItem = DispatchWorkItem { [weak self] in
558
            guard let self else { return }
559
            self.pendingDataDumpWorkItem = nil
560
            self.dataDumpRequest()
561
        }
562
        pendingDataDumpWorkItem = workItem
563
        track("\(name) - Schedule data request in \(String(format: "%.2f", delay))s (\(reason))")
564
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
565
    }
Bogdan Timofte authored 2 weeks ago
566

            
567
    private func noteInitiatedVolatileMemoryResetIfNeeded(for groupID: UInt8) {
568
        guard groupID == 0 else { return }
569
        pendingVolatileMemoryResetIgnoreCount += 1
570
        pendingVolatileMemoryResetDeadline = Date().addingTimeInterval(initiatedVolatileMemoryResetGraceWindow)
571
        track("\(name) - Will ignore the next volatile memory drop caused by an app reset request.")
572
    }
573

            
574
    private func pendingInitiatedVolatileMemoryResetIsActive(at timestamp: Date) -> Bool {
575
        guard let pendingVolatileMemoryResetDeadline else { return false }
576
        guard pendingVolatileMemoryResetIgnoreCount > 0 else {
577
            self.pendingVolatileMemoryResetDeadline = nil
578
            return false
579
        }
580
        guard timestamp <= pendingVolatileMemoryResetDeadline else {
581
            track("\(name) - Expiring stale volatile memory reset ignore state.")
582
            pendingVolatileMemoryResetIgnoreCount = 0
583
            self.pendingVolatileMemoryResetDeadline = nil
584
            return false
585
        }
586
        return true
587
    }
588

            
589
    private func shouldIgnoreVolatileMemoryDrop(at timestamp: Date) -> Bool {
590
        guard pendingInitiatedVolatileMemoryResetIsActive(at: timestamp) else { return false }
591
        pendingVolatileMemoryResetIgnoreCount -= 1
592
        if pendingVolatileMemoryResetIgnoreCount == 0 {
593
            pendingVolatileMemoryResetDeadline = nil
594
        }
595
        track("\(name) - Ignoring volatile memory drop after an app-initiated reset.")
596
        return true
597
    }
598

            
599
    private func didUMVolatileMemoryDecrease(in snapshot: UMSnapshot) -> Bool {
600
        guard hasSeenUMSnapshot else { return false }
601
        guard let previousRecord = dataGroupRecords[0], let nextRecord = snapshot.dataGroupRecords[0] else {
602
            return false
603
        }
604

            
605
        return nextRecord.ah < (previousRecord.ah - volatileMemoryDecreaseEpsilon)
606
            || nextRecord.wh < (previousRecord.wh - volatileMemoryDecreaseEpsilon)
607
    }
608

            
609
    private func didUMDeviceReboot(with snapshot: UMSnapshot, at timestamp: Date) -> Bool {
610
        defer { hasSeenUMSnapshot = true }
611

            
612
        guard didUMVolatileMemoryDecrease(in: snapshot) else { return false }
613
        guard !shouldIgnoreVolatileMemoryDrop(at: timestamp) else { return false }
614

            
615
        track("\(name) - Inferred UM reboot because volatile memory dropped.")
616
        return true
617
    }
618

            
619
    private func didTC66DeviceReboot(with snapshot: TC66Snapshot) -> Bool {
620
        guard hasSeenTC66Snapshot else { return false }
621
        guard snapshot.bootCount != bootCount else { return false }
622

            
623
        track("\(name) - Inferred TC66 reboot because bootCount changed from \(bootCount) to \(snapshot.bootCount).")
624
        return true
625
    }
626

            
627
    private func updateChargerTypeLatch(observedIndex: UInt16, didDetectDeviceReset: Bool) {
628
        if didDetectDeviceReset, chargerTypeIndex != 0 {
629
            chargerTypeIndex = 0
630
        }
631

            
632
        guard supportsChargerDetection else { return }
633

            
634
        if chargerTypeIndex == 0 {
635
            chargerTypeIndex = observedIndex
636
            return
637
        }
638

            
639
        guard observedIndex != 0, observedIndex != chargerTypeIndex else { return }
640
        track("\(name) - Ignoring charger type change from \(chargerTypeIndex) to \(observedIndex) until the device reboots.")
641
    }
Bogdan Timofte authored 2 weeks ago
642

            
643
    func dataDumpRequest() {
Bogdan Timofte authored 2 weeks ago
644
        guard operationalState >= .peripheralReady else {
645
            track("\(name) - Skip data request while state is \(operationalState)")
646
            return
647
        }
Bogdan Timofte authored 2 weeks ago
648
        if commandQueue.isEmpty {
649
            switch model {
650
            case .UM25C:
Bogdan Timofte authored 2 weeks ago
651
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
Bogdan Timofte authored 2 weeks ago
652
            case .UM34C:
Bogdan Timofte authored 2 weeks ago
653
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
Bogdan Timofte authored 2 weeks ago
654
            case .TC66C:
Bogdan Timofte authored 2 weeks ago
655
                btSerial.write(TC66Protocol.snapshotRequest, expectedResponseLength: 192)
Bogdan Timofte authored 2 weeks ago
656
            }
657
            dataDumpRequestTimestamp = Date()
658
            // track("\(name) - Request sent!")
659
        } else {
660
            track("Request delayed for: \(commandQueue.first!.hexEncodedStringValue)")
661
            btSerial.write( commandQueue.first! )
662
            commandQueue.removeFirst()
Bogdan Timofte authored 2 weeks ago
663
            scheduleDataDumpRequest(after: 1, reason: "queued command")
Bogdan Timofte authored 2 weeks ago
664
        }
665
    }
666

            
667
    /**
668
     received data parser
669
     - parameter buffer cotains response for data dump request
670
     - Decription metod for TC66C AES ECB response  found [here](https:github.com/krzyzanowskim/CryptoSwift/issues/693)
671
     */
672
    func parseData ( from buffer: Data) {
673
        //track("\(name)")
674
        switch model {
675
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
676
            do {
677
                apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
678
            } catch {
679
                track("\(name) - Error: \(error)")
680
            }
Bogdan Timofte authored 2 weeks ago
681
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
682
            do {
683
                apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
684
            } catch {
685
                track("\(name) - Error: \(error)")
686
            }
Bogdan Timofte authored 2 weeks ago
687
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
688
            do {
689
                apply(tc66Snapshot: try TC66Protocol.parseSnapshot(from: buffer))
690
            } catch {
691
                track("\(name) - Error: \(error)")
692
            }
Bogdan Timofte authored 2 weeks ago
693
        }
Bogdan Timofte authored 2 weeks ago
694
        updateChargeRecord(at: dataDumpRequestTimestamp)
Bogdan Timofte authored 2 weeks ago
695
        measurements.addValues(timestamp: dataDumpRequestTimestamp, power: power, voltage: voltage, current: current)
696
//        DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
697
//            //track("\(name) - Scheduled new request.")
698
//        }
699
        operationalState = .dataIsAvailable
700
        dataDumpRequest()
701
    }
702

            
Bogdan Timofte authored 2 weeks ago
703
    private func apply(umSnapshot snapshot: UMSnapshot) {
Bogdan Timofte authored 2 weeks ago
704
        let didDetectDeviceReset = didUMDeviceReboot(with: snapshot, at: dataDumpRequestTimestamp)
Bogdan Timofte authored 2 weeks ago
705
        modelNumber = snapshot.modelNumber
706
        voltage = snapshot.voltage
707
        current = snapshot.current
708
        power = snapshot.power
709
        temperatureCelsius = snapshot.temperatureCelsius
710
        temperatureFahrenheit = snapshot.temperatureFahrenheit
711
        selectedDataGroup = snapshot.selectedDataGroup
712
        for (index, record) in snapshot.dataGroupRecords {
713
            dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
Bogdan Timofte authored 2 weeks ago
714
        }
Bogdan Timofte authored 2 weeks ago
715
        usbPlusVoltage = snapshot.usbPlusVoltage
716
        usbMinusVoltage = snapshot.usbMinusVoltage
Bogdan Timofte authored 2 weeks ago
717
        updateChargerTypeLatch(observedIndex: snapshot.chargerTypeIndex, didDetectDeviceReset: didDetectDeviceReset)
Bogdan Timofte authored 2 weeks ago
718
        recordedAH = snapshot.recordedAH
719
        recordedWH = snapshot.recordedWH
Bogdan Timofte authored 2 weeks ago
720

            
721
        if recordingThresholdTimestamp < dataDumpRequestTimestamp || !recordingThresholdLoadedFromDevice {
722
            recordingThresholdLoadedFromDevice = true
723
            if recordingTreshold != snapshot.recordingThreshold {
724
                isApplyingRecordingThresholdFromDevice = true
725
                recordingTreshold = snapshot.recordingThreshold
726
                isApplyingRecordingThresholdFromDevice = false
727
            }
728
        } else {
729
            track("\(name) - Skip updating recordingThreshold (changed after request).")
730
        }
Bogdan Timofte authored 2 weeks ago
731
        recordingDuration = snapshot.recordingDuration
732
        recording = snapshot.recording
733

            
Bogdan Timofte authored 2 weeks ago
734
        if screenTimeoutTimestamp < dataDumpRequestTimestamp {
Bogdan Timofte authored 2 weeks ago
735
            if screenTimeout != snapshot.screenTimeout {
736
                screenTimeout = snapshot.screenTimeout
Bogdan Timofte authored 2 weeks ago
737
            }
738
        } else {
739
            track("\(name) - Skip updating screenTimeout (changed after request).")
740
        }
741

            
742
        if screenBrightnessTimestamp < dataDumpRequestTimestamp {
Bogdan Timofte authored 2 weeks ago
743
            if screenBrightness != snapshot.screenBrightness {
744
                screenBrightness = snapshot.screenBrightness
Bogdan Timofte authored 2 weeks ago
745
            }
746
        } else {
747
            track("\(name) - Skip updating screenBrightness (changed after request).")
748
        }
749

            
Bogdan Timofte authored 2 weeks ago
750
        currentScreen = snapshot.currentScreen
751
        loadResistance = snapshot.loadResistance
Bogdan Timofte authored 2 weeks ago
752
    }
753

            
Bogdan Timofte authored 2 weeks ago
754
    private func apply(tc66Snapshot snapshot: TC66Snapshot) {
Bogdan Timofte authored 2 weeks ago
755
        let didDetectDeviceReset = didTC66DeviceReboot(with: snapshot)
Bogdan Timofte authored 2 weeks ago
756
        if hasSeenTC66Snapshot {
757
            inferTC66ActiveDataGroup(from: snapshot)
758
        } else {
759
            hasSeenTC66Snapshot = true
760
        }
Bogdan Timofte authored 2 weeks ago
761
        reportedModelName = snapshot.modelName
762
        firmwareVersion = snapshot.firmwareVersion
763
        serialNumber = snapshot.serialNumber
764
        bootCount = snapshot.bootCount
Bogdan Timofte authored 2 weeks ago
765
        updateChargerTypeLatch(observedIndex: 0, didDetectDeviceReset: didDetectDeviceReset)
Bogdan Timofte authored 2 weeks ago
766
        voltage = snapshot.voltage
767
        current = snapshot.current
768
        power = snapshot.power
769
        loadResistance = snapshot.loadResistance
770
        for (index, record) in snapshot.dataGroupRecords {
771
            dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
Bogdan Timofte authored 2 weeks ago
772
        }
Bogdan Timofte authored 2 weeks ago
773
        temperatureCelsius = snapshot.temperatureCelsius
774
        usbPlusVoltage = snapshot.usbPlusVoltage
775
        usbMinusVoltage = snapshot.usbMinusVoltage
Bogdan Timofte authored 2 weeks ago
776
    }
Bogdan Timofte authored 2 weeks ago
777

            
778
    private func inferTC66ActiveDataGroup(from snapshot: TC66Snapshot) {
779
        let candidate = snapshot.dataGroupRecords.compactMap { entry -> (UInt8, Double)? in
780
            let index = entry.key
781
            let record = entry.value
782
            guard let previous = dataGroupRecords[index] else { return nil }
783
            let deltaAH = max(record.ah - previous.ah, 0)
784
            let deltaWH = max(record.wh - previous.wh, 0)
785
            let score = deltaAH + deltaWH
786
            guard score > 0 else { return nil }
787
            return (UInt8(index), score)
788
        }
789
        .max { lhs, rhs in lhs.1 < rhs.1 }
790

            
791
        if let candidate {
792
            selectedDataGroup = candidate.0
793
            hasObservedActiveDataGroup = true
794
        }
795
    }
796

            
797
    private func updateChargeRecord(at timestamp: Date) {
798
        switch chargeRecordState {
799
        case .waitingForStart:
800
            guard current > chargeRecordStopThreshold else { return }
801
            chargeRecordState = .active
802
            chargeRecordStartTimestamp = timestamp
803
            chargeRecordEndTimestamp = timestamp
804
            chargeRecordLastTimestamp = timestamp
805
            chargeRecordLastCurrent = current
806
            chargeRecordLastPower = power
807
        case .active:
808
            if let lastTimestamp = chargeRecordLastTimestamp {
809
                let deltaSeconds = max(timestamp.timeIntervalSince(lastTimestamp), 0)
810
                chargeRecordAH += chargeRecordLastCurrent * deltaSeconds / 3600
811
                chargeRecordWH += chargeRecordLastPower * deltaSeconds / 3600
812
                chargeRecordDuration += deltaSeconds
813
            }
814
            chargeRecordEndTimestamp = timestamp
815
            chargeRecordLastTimestamp = timestamp
816
            chargeRecordLastCurrent = current
817
            chargeRecordLastPower = power
818
            if current <= chargeRecordStopThreshold {
819
                chargeRecordState = .completed
820
            }
821
        case .completed:
822
            break
823
        }
824
    }
825

            
826
    func resetChargeRecord() {
827
        chargeRecordAH = 0
828
        chargeRecordWH = 0
829
        chargeRecordDuration = 0
830
        chargeRecordState = .waitingForStart
831
        chargeRecordStartTimestamp = nil
832
        chargeRecordEndTimestamp = nil
833
        chargeRecordLastTimestamp = nil
834
        chargeRecordLastCurrent = 0
835
        chargeRecordLastPower = 0
836
    }
837

            
838
    func resetChargeRecordGraph() {
839
        let cutoff = Date()
840
        resetChargeRecord()
841
        measurements.trim(before: cutoff)
842
    }
Bogdan Timofte authored 2 weeks ago
843

            
844
    func nextScreen() {
845
        switch model {
846
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
847
            commandQueue.append(UMProtocol.nextScreen)
Bogdan Timofte authored 2 weeks ago
848
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
849
            commandQueue.append(UMProtocol.nextScreen)
Bogdan Timofte authored 2 weeks ago
850
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
851
            commandQueue.append(TC66Protocol.nextPage)
Bogdan Timofte authored 2 weeks ago
852
        }
853
    }
854

            
855
    func rotateScreen() {
856
        switch model {
857
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
858
            commandQueue.append(UMProtocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
859
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
860
            commandQueue.append(UMProtocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
861
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
862
            commandQueue.append(TC66Protocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
863
        }
864
    }
865

            
866
    func previousScreen() {
867
        switch model {
868
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
869
            commandQueue.append(UMProtocol.previousScreen)
Bogdan Timofte authored 2 weeks ago
870
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
871
            commandQueue.append(UMProtocol.previousScreen)
Bogdan Timofte authored 2 weeks ago
872
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
873
            commandQueue.append(TC66Protocol.previousPage)
Bogdan Timofte authored 2 weeks ago
874
        }
875
    }
876

            
877
    func clear() {
Bogdan Timofte authored 2 weeks ago
878
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
879
        noteInitiatedVolatileMemoryResetIfNeeded(for: selectedDataGroup)
Bogdan Timofte authored 2 weeks ago
880
        commandQueue.append(UMProtocol.clearCurrentGroup)
Bogdan Timofte authored 2 weeks ago
881
    }
882

            
883
    func clear(group id: UInt8) {
Bogdan Timofte authored 2 weeks ago
884
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
885
        commandQueue.append(UMProtocol.selectDataGroup(id))
Bogdan Timofte authored 2 weeks ago
886
        noteInitiatedVolatileMemoryResetIfNeeded(for: id)
887
        commandQueue.append(UMProtocol.clearCurrentGroup)
Bogdan Timofte authored 2 weeks ago
888
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
Bogdan Timofte authored 2 weeks ago
889
    }
890

            
891
    func selectDataGroup ( id: UInt8) {
Bogdan Timofte authored 2 weeks ago
892
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
893
        track("\(name) - \(id)")
894
        selectedDataGroup = id
Bogdan Timofte authored 2 weeks ago
895
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
Bogdan Timofte authored 2 weeks ago
896
    }
897

            
898
    private func setSceeenBrightness ( to value: UInt8) {
899
        track("\(name) - \(value)")
Bogdan Timofte authored 2 weeks ago
900
        guard supportsUMSettings else { return }
Bogdan Timofte authored 2 weeks ago
901
        commandQueue.append(UMProtocol.setScreenBrightness(value))
Bogdan Timofte authored 2 weeks ago
902
    }
903
    private func setScreenSaverTimeout ( to value: UInt8) {
904
        track("\(name) - \(value)")
Bogdan Timofte authored 2 weeks ago
905
        guard supportsUMSettings else { return }
Bogdan Timofte authored 2 weeks ago
906
        commandQueue.append(UMProtocol.setScreenSaverTimeout(value))
Bogdan Timofte authored 2 weeks ago
907
    }
908
    func setrecordingTreshold ( to value: UInt8) {
Bogdan Timofte authored 2 weeks ago
909
        guard supportsRecordingThreshold else { return }
Bogdan Timofte authored 2 weeks ago
910
        commandQueue.append(UMProtocol.setRecordingThreshold(value))
Bogdan Timofte authored 2 weeks ago
911
    }
912

            
913
    /**
914
     Connect to meter.
915
     1. It calls BluetoothSerial.connect
916
     */
917
    func connect() {
918
        enableAutoConnect = true
919
        btSerial.connect()
920
    }
921

            
922
    /**
923
     Disconnect from meter.
924
        It calls BluetoothSerial.disconnect
925
     */
926
    func disconnect() {
927
        enableAutoConnect = false
928
        btSerial.disconnect()
929
    }
930
}
931

            
932
extension Meter : SerialPortDelegate {
933

            
934
    func opertionalStateChanged(to serialPortOperationalState: BluetoothSerial.OperationalState) {
Bogdan Timofte authored 2 weeks ago
935
        let applyStateChange = {
936
            self.lastSeen = Date()
937
            switch serialPortOperationalState {
938
            case .peripheralNotConnected:
939
                self.operationalState = .peripheralNotConnected
940
            case .peripheralConnectionPending:
941
                self.operationalState = .peripheralConnectionPending
942
            case .peripheralConnected:
943
                self.operationalState = .peripheralConnected
944
            case .peripheralReady:
945
                self.operationalState = .peripheralReady
946
            }
947
        }
948

            
949
        if Thread.isMainThread {
950
            applyStateChange()
951
        } else {
952
            DispatchQueue.main.async(execute: applyStateChange)
Bogdan Timofte authored 2 weeks ago
953
        }
954
    }
955

            
956
    func didReceiveData(_ data: Data) {
Bogdan Timofte authored 2 weeks ago
957
        let applyData = {
958
            self.lastSeen = Date()
959
            self.operationalState = .comunicating
960
            self.parseData(from: data)
961
        }
962

            
963
        if Thread.isMainThread {
964
            applyData()
965
        } else {
966
            DispatchQueue.main.async(execute: applyData)
967
        }
Bogdan Timofte authored 2 weeks ago
968
    }
969
}