Newer Older
966 lines | 33.672kb
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 {
333
        reportedModelName.isEmpty ? modelString : reportedModelName
334
    }
335

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

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

            
350
        if hours > 0 {
351
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
352
        }
353
        return String(format: "%02d:%02d", minutes, seconds)
354
    }
355

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

            
362
        if hours > 0 {
363
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
364
        }
365
        return String(format: "%02d:%02d", minutes, seconds)
366
    }
367

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

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

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

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

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

            
411
    var recordingThresholdHint: String? {
412
        capabilities.recordingThresholdHint
413
    }
414

            
Bogdan Timofte authored 2 weeks ago
415
    @Published var btSerial: BluetoothSerial
416

            
417
    @Published var measurements = Measurements()
418

            
419
    private var commandQueue: [Data] = []
420
    private var dataDumpRequestTimestamp = Date()
Bogdan Timofte authored 2 weeks ago
421
    private var pendingDataDumpWorkItem: DispatchWorkItem?
Bogdan Timofte authored 2 weeks ago
422

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

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

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

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

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

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

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

            
551
    private func scheduleDataDumpRequest(after delay: TimeInterval, reason: String) {
552
        cancelPendingDataDumpRequest(reason: "reschedule")
553

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

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

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

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

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

            
602
        return nextRecord.ah < (previousRecord.ah - volatileMemoryDecreaseEpsilon)
603
            || nextRecord.wh < (previousRecord.wh - volatileMemoryDecreaseEpsilon)
604
    }
605

            
606
    private func didUMDeviceReboot(with snapshot: UMSnapshot, at timestamp: Date) -> Bool {
607
        defer { hasSeenUMSnapshot = true }
608

            
609
        guard didUMVolatileMemoryDecrease(in: snapshot) else { return false }
610
        guard !shouldIgnoreVolatileMemoryDrop(at: timestamp) else { return false }
611

            
612
        track("\(name) - Inferred UM reboot because volatile memory dropped.")
613
        return true
614
    }
615

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

            
620
        track("\(name) - Inferred TC66 reboot because bootCount changed from \(bootCount) to \(snapshot.bootCount).")
621
        return true
622
    }
623

            
624
    private func updateChargerTypeLatch(observedIndex: UInt16, didDetectDeviceReset: Bool) {
625
        if didDetectDeviceReset, chargerTypeIndex != 0 {
626
            chargerTypeIndex = 0
627
        }
628

            
629
        guard supportsChargerDetection else { return }
630

            
631
        if chargerTypeIndex == 0 {
632
            chargerTypeIndex = observedIndex
633
            return
634
        }
635

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

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

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

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

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

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

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

            
Bogdan Timofte authored 2 weeks ago
747
        currentScreen = snapshot.currentScreen
748
        loadResistance = snapshot.loadResistance
Bogdan Timofte authored 2 weeks ago
749
    }
750

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

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

            
788
        if let candidate {
789
            selectedDataGroup = candidate.0
790
            hasObservedActiveDataGroup = true
791
        }
792
    }
793

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

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

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

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

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

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

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

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

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

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

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

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

            
929
extension Meter : SerialPortDelegate {
930

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

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

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

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