Newer Older
962 lines | 33.607kb
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 deviceModelSummary: String {
333
        let baseName = reportedModelName.isEmpty ? modelString : reportedModelName
334
        if modelNumber != 0 {
335
            return "\(baseName) (\(modelNumber))"
336
        }
337
        return baseName
338
    }
339

            
Bogdan Timofte authored 2 weeks ago
340
    var recordingDurationDescription: String {
341
        let totalSeconds = Int(recordingDuration)
342
        let hours = totalSeconds / 3600
343
        let minutes = (totalSeconds % 3600) / 60
344
        let seconds = totalSeconds % 60
345

            
346
        if hours > 0 {
347
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
348
        }
349
        return String(format: "%02d:%02d", minutes, seconds)
350
    }
351

            
Bogdan Timofte authored 2 weeks ago
352
    var chargeRecordDurationDescription: String {
353
        let totalSeconds = Int(chargeRecordDuration)
354
        let hours = totalSeconds / 3600
355
        let minutes = (totalSeconds % 3600) / 60
356
        let seconds = totalSeconds % 60
357

            
358
        if hours > 0 {
359
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
360
        }
361
        return String(format: "%02d:%02d", minutes, seconds)
362
    }
363

            
364
    var chargeRecordTimeRange: ClosedRange<Date>? {
365
        guard let start = chargeRecordStartTimestamp else { return nil }
366
        let end = chargeRecordEndTimestamp ?? chargeRecordLastTimestamp
367
        guard let end else { return nil }
368
        return start...end
369
    }
370

            
371
    var chargeRecordStatusText: String {
372
        switch chargeRecordState {
373
        case .waitingForStart:
374
            return "Waiting"
375
        case .active:
376
            return "Active"
377
        case .completed:
378
            return "Completed"
379
        }
380
    }
381

            
382
    var chargeRecordStatusColor: Color {
383
        switch chargeRecordState {
384
        case .waitingForStart:
385
            return .secondary
386
        case .active:
387
            return .red
388
        case .completed:
389
            return .green
390
        }
391
    }
392

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

            
403
    func dataGroupLabel(for id: UInt8) -> String {
404
        supportsDataGroupCommands ? "\(id)" : "M\(Int(id) + 1)"
Bogdan Timofte authored 2 weeks ago
405
    }
406

            
407
    var recordingThresholdHint: String? {
408
        capabilities.recordingThresholdHint
409
    }
410

            
Bogdan Timofte authored 2 weeks ago
411
    @Published var btSerial: BluetoothSerial
412

            
413
    @Published var measurements = Measurements()
414

            
415
    private var commandQueue: [Data] = []
416
    private var dataDumpRequestTimestamp = Date()
Bogdan Timofte authored 2 weeks ago
417
    private var pendingDataDumpWorkItem: DispatchWorkItem?
Bogdan Timofte authored 2 weeks ago
418

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

            
443
    @Published var screenBrightness: Int = -1 {
444
        didSet {
445
            if oldValue != screenBrightness {
446
                screenBrightnessTimestamp = Date()
447
                if oldValue != -1 {
448
                    setSceeenBrightness(to: UInt8(screenBrightness))
449
                }
450
            }
451
        }
452
    }
453
    private var screenBrightnessTimestamp = Date()
454

            
455
    @Published var screenTimeout: Int = -1 {
456
        didSet {
457
            if oldValue != screenTimeout {
458
                screenTimeoutTimestamp = Date()
459
                if oldValue != -1 {
460
                    setScreenSaverTimeout(to: UInt8(screenTimeout))
461
                }
462
            }
463
        }
464
    }
465
    private var screenTimeoutTimestamp = Date()
466

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

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

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

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

            
547
    private func scheduleDataDumpRequest(after delay: TimeInterval, reason: String) {
548
        cancelPendingDataDumpRequest(reason: "reschedule")
549

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

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

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

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

            
592
    private func didUMVolatileMemoryDecrease(in snapshot: UMSnapshot) -> Bool {
593
        guard hasSeenUMSnapshot else { return false }
594
        guard let previousRecord = dataGroupRecords[0], let nextRecord = snapshot.dataGroupRecords[0] else {
595
            return false
596
        }
597

            
598
        return nextRecord.ah < (previousRecord.ah - volatileMemoryDecreaseEpsilon)
599
            || nextRecord.wh < (previousRecord.wh - volatileMemoryDecreaseEpsilon)
600
    }
601

            
602
    private func didUMDeviceReboot(with snapshot: UMSnapshot, at timestamp: Date) -> Bool {
603
        defer { hasSeenUMSnapshot = true }
604

            
605
        guard didUMVolatileMemoryDecrease(in: snapshot) else { return false }
606
        guard !shouldIgnoreVolatileMemoryDrop(at: timestamp) else { return false }
607

            
608
        track("\(name) - Inferred UM reboot because volatile memory dropped.")
609
        return true
610
    }
611

            
612
    private func didTC66DeviceReboot(with snapshot: TC66Snapshot) -> Bool {
613
        guard hasSeenTC66Snapshot else { return false }
614
        guard snapshot.bootCount != bootCount else { return false }
615

            
616
        track("\(name) - Inferred TC66 reboot because bootCount changed from \(bootCount) to \(snapshot.bootCount).")
617
        return true
618
    }
619

            
620
    private func updateChargerTypeLatch(observedIndex: UInt16, didDetectDeviceReset: Bool) {
621
        if didDetectDeviceReset, chargerTypeIndex != 0 {
622
            chargerTypeIndex = 0
623
        }
624

            
625
        guard supportsChargerDetection else { return }
626

            
627
        if chargerTypeIndex == 0 {
628
            chargerTypeIndex = observedIndex
629
            return
630
        }
631

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

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

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

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

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

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

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

            
Bogdan Timofte authored 2 weeks ago
743
        currentScreen = snapshot.currentScreen
744
        loadResistance = snapshot.loadResistance
Bogdan Timofte authored 2 weeks ago
745
    }
746

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

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

            
784
        if let candidate {
785
            selectedDataGroup = candidate.0
786
            hasObservedActiveDataGroup = true
787
        }
788
    }
789

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

            
819
    func resetChargeRecord() {
820
        chargeRecordAH = 0
821
        chargeRecordWH = 0
822
        chargeRecordDuration = 0
823
        chargeRecordState = .waitingForStart
824
        chargeRecordStartTimestamp = nil
825
        chargeRecordEndTimestamp = nil
826
        chargeRecordLastTimestamp = nil
827
        chargeRecordLastCurrent = 0
828
        chargeRecordLastPower = 0
829
    }
830

            
831
    func resetChargeRecordGraph() {
832
        let cutoff = Date()
833
        resetChargeRecord()
834
        measurements.trim(before: cutoff)
835
    }
Bogdan Timofte authored 2 weeks ago
836

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

            
848
    func rotateScreen() {
849
        switch model {
850
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
851
            commandQueue.append(UMProtocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
852
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
853
            commandQueue.append(UMProtocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
854
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
855
            commandQueue.append(TC66Protocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
856
        }
857
    }
858

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

            
870
    func clear() {
Bogdan Timofte authored 2 weeks ago
871
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
872
        noteInitiatedVolatileMemoryResetIfNeeded(for: selectedDataGroup)
Bogdan Timofte authored 2 weeks ago
873
        commandQueue.append(UMProtocol.clearCurrentGroup)
Bogdan Timofte authored 2 weeks ago
874
    }
875

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

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

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

            
906
    /**
907
     Connect to meter.
908
     1. It calls BluetoothSerial.connect
909
     */
910
    func connect() {
911
        enableAutoConnect = true
912
        btSerial.connect()
913
    }
914

            
915
    /**
916
     Disconnect from meter.
917
        It calls BluetoothSerial.disconnect
918
     */
919
    func disconnect() {
920
        enableAutoConnect = false
921
        btSerial.disconnect()
922
    }
923
}
924

            
925
extension Meter : SerialPortDelegate {
926

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

            
942
        if Thread.isMainThread {
943
            applyStateChange()
944
        } else {
945
            DispatchQueue.main.async(execute: applyStateChange)
Bogdan Timofte authored 2 weeks ago
946
        }
947
    }
948

            
949
    func didReceiveData(_ data: Data) {
Bogdan Timofte authored 2 weeks ago
950
        let applyData = {
951
            self.lastSeen = Date()
952
            self.operationalState = .comunicating
953
            self.parseData(from: data)
954
        }
955

            
956
        if Thread.isMainThread {
957
            applyData()
958
        } else {
959
            DispatchQueue.main.async(execute: applyData)
960
        }
Bogdan Timofte authored 2 weeks ago
961
    }
962
}