Newer Older
951 lines | 33.155kb
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

            
75
    enum OperationalState: Int, Comparable {
76
        case notPresent
77
        case peripheralNotConnected
78
        case peripheralConnectionPending
79
        case peripheralConnected
80
        case peripheralReady
81
        case comunicating
82
        case dataIsAvailable
83

            
84
        static func < (lhs: OperationalState, rhs: OperationalState) -> Bool {
85
            return lhs.rawValue < rhs.rawValue
86
        }
87
    }
88

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

            
123
    static func operationalColor(for state: OperationalState) -> Color {
124
        switch state {
125
        case .notPresent:
126
            return .red
127
        case .peripheralNotConnected:
128
            return .blue
129
        case .peripheralConnectionPending:
130
            return .yellow
131
        case .peripheralConnected:
132
            return .yellow
133
        case .peripheralReady:
134
            return .orange
135
        case .comunicating:
136
            return .orange
137
        case .dataIsAvailable:
138
            return .green
139
        }
140
    }
141

            
142
    private var wdTimer: Timer?
143

            
144
    @Published var lastSeen = Date() {
145
        didSet {
146
            wdTimer?.invalidate()
147
            if operationalState == .peripheralNotConnected {
148
                wdTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false, block: {_ in
149
                    track("\(self.name) - Lost advertisments...")
150
                    self.operationalState = .notPresent
151
                })
152
            } else if operationalState == .notPresent {
153
               operationalState = .peripheralNotConnected
154
            }
155
        }
156
    }
157

            
158
    var uuid: UUID
159
    var model: Model
160
    var modelString: String
161

            
162
    var name: String {
163
        didSet {
164
            appData.meterNames[btSerial.macAddress.description] = name
165
        }
166
    }
167

            
168
    var color : Color {
169
        get {
Bogdan Timofte authored 2 weeks ago
170
            return model.color
Bogdan Timofte authored 2 weeks ago
171
        }
172
    }
173

            
Bogdan Timofte authored 2 weeks ago
174
    var capabilities: MeterCapabilities {
175
        model.capabilities
176
    }
177

            
Bogdan Timofte authored 2 weeks ago
178
    var availableDataGroupIDs: [UInt8] {
Bogdan Timofte authored 2 weeks ago
179
        capabilities.availableDataGroupIDs
Bogdan Timofte authored 2 weeks ago
180
    }
181

            
182
    var supportsDataGroupCommands: Bool {
Bogdan Timofte authored 2 weeks ago
183
        capabilities.supportsDataGroupCommands
Bogdan Timofte authored 2 weeks ago
184
    }
185

            
Bogdan Timofte authored 2 weeks ago
186
    var supportsRecordingView: Bool {
187
        capabilities.supportsRecordingView
188
    }
189

            
Bogdan Timofte authored 2 weeks ago
190
    var supportsUMSettings: Bool {
Bogdan Timofte authored 2 weeks ago
191
        capabilities.supportsScreenSettings
Bogdan Timofte authored 2 weeks ago
192
    }
193

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

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

            
202
    var showsDataGroupEnergy: Bool {
203
        capabilities.showsDataGroupEnergy
204
    }
205

            
206
    var highlightsActiveDataGroup: Bool {
207
        if model == .TC66C {
208
            return hasObservedActiveDataGroup
209
        }
210
        return capabilities.highlightsActiveDataGroup
211
    }
212

            
Bogdan Timofte authored 2 weeks ago
213
    var supportsFahrenheit: Bool {
Bogdan Timofte authored 2 weeks ago
214
        capabilities.supportsFahrenheit
Bogdan Timofte authored 2 weeks ago
215
    }
216

            
Bogdan Timofte authored 2 weeks ago
217
    var supportsManualTemperatureUnitSelection: Bool {
218
        model == .TC66C
219
    }
220

            
Bogdan Timofte authored 2 weeks ago
221
    var supportsChargerDetection: Bool {
Bogdan Timofte authored 2 weeks ago
222
        capabilities.supportsChargerDetection
223
    }
224

            
Bogdan Timofte authored 2 weeks ago
225
    var dataGroupsTitle: String {
226
        capabilities.dataGroupsTitle
227
    }
228

            
Bogdan Timofte authored 2 weeks ago
229
    var documentedWorkingVoltage: String {
230
        capabilities.documentedWorkingVoltage
231
    }
232

            
Bogdan Timofte authored 2 weeks ago
233
    var chargerTypeDescription: String {
234
        capabilities.chargerTypeDescription(for: chargerTypeIndex)
Bogdan Timofte authored 2 weeks ago
235
    }
236

            
Bogdan Timofte authored 2 weeks ago
237
    var temperatureUnitDescription: String {
238
        if supportsManualTemperatureUnitSelection {
Bogdan Timofte authored 2 weeks ago
239
            return "Device-defined"
Bogdan Timofte authored 2 weeks ago
240
        }
Bogdan Timofte authored 2 weeks ago
241
        return systemTemperatureUnitPreference.localeTitle
Bogdan Timofte authored 2 weeks ago
242
    }
243

            
244
    var primaryTemperatureDescription: String {
Bogdan Timofte authored 2 weeks ago
245
        let value = displayedTemperatureValue.format(decimalDigits: 0)
Bogdan Timofte authored 2 weeks ago
246
        if supportsManualTemperatureUnitSelection {
Bogdan Timofte authored 2 weeks ago
247
            return "\(value)°"
Bogdan Timofte authored 2 weeks ago
248
        }
Bogdan Timofte authored 2 weeks ago
249
        return "\(value)\(systemTemperatureUnitPreference.symbol)"
Bogdan Timofte authored 2 weeks ago
250
    }
251

            
252
    var secondaryTemperatureDescription: String? {
Bogdan Timofte authored 2 weeks ago
253
        nil
254
    }
255

            
256
    var displayedTemperatureValue: Double {
257
        if supportsManualTemperatureUnitSelection {
258
            return temperatureCelsius
259
        }
260
        switch systemTemperatureUnitPreference {
261
        case .celsius:
262
            return displayedTemperatureCelsius
263
        case .fahrenheit:
264
            return displayedTemperatureFahrenheit
265
        }
266
    }
267

            
268
    private var displayedTemperatureCelsius: Double {
269
        if supportsManualTemperatureUnitSelection {
270
            switch tc66TemperatureUnitPreference {
271
            case .celsius:
272
                return temperatureCelsius
273
            case .fahrenheit:
274
                return (temperatureCelsius - 32) * 5 / 9
275
            }
276
        }
277
        return temperatureCelsius
278
    }
279

            
280
    private var displayedTemperatureFahrenheit: Double {
281
        if supportsManualTemperatureUnitSelection {
282
            switch tc66TemperatureUnitPreference {
283
            case .celsius:
284
                return (temperatureCelsius * 9 / 5) + 32
285
            case .fahrenheit:
286
                return temperatureCelsius
287
            }
288
        }
289
        if supportsFahrenheit, temperatureFahrenheit.isFinite {
290
            return temperatureFahrenheit
291
        }
292
        return (temperatureCelsius * 9 / 5) + 32
293
    }
294

            
295
    private var systemTemperatureUnitPreference: TemperatureUnitPreference {
296
        let locale = Locale.autoupdatingCurrent
297
        if #available(iOS 16.0, *) {
298
            switch locale.measurementSystem {
299
            case .us:
300
                return .fahrenheit
301
            default:
302
                return .celsius
303
            }
304
        }
305

            
306
        let regionCode = locale.regionCode ?? ""
307
        let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
308
        return fahrenheitRegions.contains(regionCode) ? .fahrenheit : .celsius
Bogdan Timofte authored 2 weeks ago
309
    }
310

            
Bogdan Timofte authored 2 weeks ago
311
    var currentScreenDescription: String {
Bogdan Timofte authored 2 weeks ago
312
        guard reportsCurrentScreenIndex else {
313
            return "Page Controls"
314
        }
Bogdan Timofte authored 2 weeks ago
315
        if let label = capabilities.screenDescription(for: currentScreen) {
316
            return "Screen \(currentScreen): \(label)"
317
        }
318
        return "Screen \(currentScreen)"
319
    }
320

            
Bogdan Timofte authored 2 weeks ago
321
    var deviceModelSummary: String {
322
        let baseName = reportedModelName.isEmpty ? modelString : reportedModelName
323
        if modelNumber != 0 {
324
            return "\(baseName) (\(modelNumber))"
325
        }
326
        return baseName
327
    }
328

            
Bogdan Timofte authored 2 weeks ago
329
    var recordingDurationDescription: String {
330
        let totalSeconds = Int(recordingDuration)
331
        let hours = totalSeconds / 3600
332
        let minutes = (totalSeconds % 3600) / 60
333
        let seconds = totalSeconds % 60
334

            
335
        if hours > 0 {
336
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
337
        }
338
        return String(format: "%02d:%02d", minutes, seconds)
339
    }
340

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

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

            
353
    var chargeRecordTimeRange: ClosedRange<Date>? {
354
        guard let start = chargeRecordStartTimestamp else { return nil }
355
        let end = chargeRecordEndTimestamp ?? chargeRecordLastTimestamp
356
        guard let end else { return nil }
357
        return start...end
358
    }
359

            
360
    var chargeRecordStatusText: String {
361
        switch chargeRecordState {
362
        case .waitingForStart:
363
            return "Waiting"
364
        case .active:
365
            return "Active"
366
        case .completed:
367
            return "Completed"
368
        }
369
    }
370

            
371
    var chargeRecordStatusColor: Color {
372
        switch chargeRecordState {
373
        case .waitingForStart:
374
            return .secondary
375
        case .active:
376
            return .red
377
        case .completed:
378
            return .green
379
        }
380
    }
381

            
Bogdan Timofte authored 2 weeks ago
382
    var dataGroupsHint: String? {
Bogdan Timofte authored 2 weeks ago
383
        if model == .TC66C {
384
            if hasObservedActiveDataGroup {
385
                return "The active memory is inferred from the totals that are currently increasing."
386
            }
387
            return "The device exposes two read-only memories. The active memory is inferred once one total starts increasing."
388
        }
389
        return capabilities.dataGroupsHint
390
    }
391

            
392
    func dataGroupLabel(for id: UInt8) -> String {
393
        supportsDataGroupCommands ? "\(id)" : "M\(Int(id) + 1)"
Bogdan Timofte authored 2 weeks ago
394
    }
395

            
396
    var recordingThresholdHint: String? {
397
        capabilities.recordingThresholdHint
398
    }
399

            
Bogdan Timofte authored 2 weeks ago
400
    @Published var btSerial: BluetoothSerial
401

            
402
    @Published var measurements = Measurements()
403

            
404
    private var commandQueue: [Data] = []
405
    private var dataDumpRequestTimestamp = Date()
Bogdan Timofte authored 2 weeks ago
406
    private var pendingDataDumpWorkItem: DispatchWorkItem?
Bogdan Timofte authored 2 weeks ago
407

            
408
    class DataGroupRecord {
409
        @Published var ah: Double
410
        @Published var wh: Double
411
        init(ah: Double, wh: Double) {
412
            self.ah = ah
413
            self.wh = wh
414
        }
415
    }
416
    @Published var selectedDataGroup: UInt8 = 0
417
    @Published var dataGroupRecords: [Int : DataGroupRecord] = [:]
Bogdan Timofte authored 2 weeks ago
418
    @Published var chargeRecordAH: Double = 0
419
    @Published var chargeRecordWH: Double = 0
420
    @Published var chargeRecordDuration: TimeInterval = 0
421
    @Published var chargeRecordStopThreshold: Double = 0.05
422
    @Published var tc66TemperatureUnitPreference: TemperatureUnitPreference = .celsius {
423
        didSet {
424
            guard supportsManualTemperatureUnitSelection else { return }
425
            guard oldValue != tc66TemperatureUnitPreference else { return }
426
            var settings = appData.tc66TemperatureUnits
427
            settings[btSerial.macAddress.description] = tc66TemperatureUnitPreference.rawValue
428
            appData.tc66TemperatureUnits = settings
429
        }
430
    }
Bogdan Timofte authored 2 weeks ago
431

            
432
    @Published var screenBrightness: Int = -1 {
433
        didSet {
434
            if oldValue != screenBrightness {
435
                screenBrightnessTimestamp = Date()
436
                if oldValue != -1 {
437
                    setSceeenBrightness(to: UInt8(screenBrightness))
438
                }
439
            }
440
        }
441
    }
442
    private var screenBrightnessTimestamp = Date()
443

            
444
    @Published var screenTimeout: Int = -1 {
445
        didSet {
446
            if oldValue != screenTimeout {
447
                screenTimeoutTimestamp = Date()
448
                if oldValue != -1 {
449
                    setScreenSaverTimeout(to: UInt8(screenTimeout))
450
                }
451
            }
452
        }
453
    }
454
    private var screenTimeoutTimestamp = Date()
455

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

            
504
    init ( model: Model, with serialPort: BluetoothSerial ) {
505
        uuid = serialPort.peripheral.identifier
506
        //dataStore.meterUUIDS.append(serialPort.peripheral.identifier)
507
        modelString = serialPort.peripheral.name!
508
        self.model = model
509
        btSerial = serialPort
510
        name = appData.meterNames[serialPort.macAddress.description] ?? serialPort.macAddress.description
511
        super.init()
512
        btSerial.delegate = self
Bogdan Timofte authored 2 weeks ago
513
        reloadTemperatureUnitPreference()
Bogdan Timofte authored 2 weeks ago
514
        //name = dataStore.meterNames[macAddress.description] ?? peripheral.name!
515
        for index in stride(from: 0, through: 9, by: 1) {
516
            dataGroupRecords[index] = DataGroupRecord(ah:0, wh: 0)
517
        }
518
    }
Bogdan Timofte authored 2 weeks ago
519

            
520
    func reloadTemperatureUnitPreference() {
521
        guard supportsManualTemperatureUnitSelection else { return }
522
        let rawValue = appData.tc66TemperatureUnits[btSerial.macAddress.description] ?? TemperatureUnitPreference.celsius.rawValue
523
        let persistedPreference = TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
524
        if tc66TemperatureUnitPreference != persistedPreference {
525
            tc66TemperatureUnitPreference = persistedPreference
526
        }
527
    }
Bogdan Timofte authored 2 weeks ago
528

            
529
    private func cancelPendingDataDumpRequest(reason: String) {
530
        guard let pendingDataDumpWorkItem else { return }
531
        track("\(name) - Cancel scheduled data request (\(reason))")
532
        pendingDataDumpWorkItem.cancel()
533
        self.pendingDataDumpWorkItem = nil
534
    }
535

            
536
    private func scheduleDataDumpRequest(after delay: TimeInterval, reason: String) {
537
        cancelPendingDataDumpRequest(reason: "reschedule")
538

            
539
        let workItem = DispatchWorkItem { [weak self] in
540
            guard let self else { return }
541
            self.pendingDataDumpWorkItem = nil
542
            self.dataDumpRequest()
543
        }
544
        pendingDataDumpWorkItem = workItem
545
        track("\(name) - Schedule data request in \(String(format: "%.2f", delay))s (\(reason))")
546
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
547
    }
Bogdan Timofte authored 2 weeks ago
548

            
549
    private func noteInitiatedVolatileMemoryResetIfNeeded(for groupID: UInt8) {
550
        guard groupID == 0 else { return }
551
        pendingVolatileMemoryResetIgnoreCount += 1
552
        pendingVolatileMemoryResetDeadline = Date().addingTimeInterval(initiatedVolatileMemoryResetGraceWindow)
553
        track("\(name) - Will ignore the next volatile memory drop caused by an app reset request.")
554
    }
555

            
556
    private func pendingInitiatedVolatileMemoryResetIsActive(at timestamp: Date) -> Bool {
557
        guard let pendingVolatileMemoryResetDeadline else { return false }
558
        guard pendingVolatileMemoryResetIgnoreCount > 0 else {
559
            self.pendingVolatileMemoryResetDeadline = nil
560
            return false
561
        }
562
        guard timestamp <= pendingVolatileMemoryResetDeadline else {
563
            track("\(name) - Expiring stale volatile memory reset ignore state.")
564
            pendingVolatileMemoryResetIgnoreCount = 0
565
            self.pendingVolatileMemoryResetDeadline = nil
566
            return false
567
        }
568
        return true
569
    }
570

            
571
    private func shouldIgnoreVolatileMemoryDrop(at timestamp: Date) -> Bool {
572
        guard pendingInitiatedVolatileMemoryResetIsActive(at: timestamp) else { return false }
573
        pendingVolatileMemoryResetIgnoreCount -= 1
574
        if pendingVolatileMemoryResetIgnoreCount == 0 {
575
            pendingVolatileMemoryResetDeadline = nil
576
        }
577
        track("\(name) - Ignoring volatile memory drop after an app-initiated reset.")
578
        return true
579
    }
580

            
581
    private func didUMVolatileMemoryDecrease(in snapshot: UMSnapshot) -> Bool {
582
        guard hasSeenUMSnapshot else { return false }
583
        guard let previousRecord = dataGroupRecords[0], let nextRecord = snapshot.dataGroupRecords[0] else {
584
            return false
585
        }
586

            
587
        return nextRecord.ah < (previousRecord.ah - volatileMemoryDecreaseEpsilon)
588
            || nextRecord.wh < (previousRecord.wh - volatileMemoryDecreaseEpsilon)
589
    }
590

            
591
    private func didUMDeviceReboot(with snapshot: UMSnapshot, at timestamp: Date) -> Bool {
592
        defer { hasSeenUMSnapshot = true }
593

            
594
        guard didUMVolatileMemoryDecrease(in: snapshot) else { return false }
595
        guard !shouldIgnoreVolatileMemoryDrop(at: timestamp) else { return false }
596

            
597
        track("\(name) - Inferred UM reboot because volatile memory dropped.")
598
        return true
599
    }
600

            
601
    private func didTC66DeviceReboot(with snapshot: TC66Snapshot) -> Bool {
602
        guard hasSeenTC66Snapshot else { return false }
603
        guard snapshot.bootCount != bootCount else { return false }
604

            
605
        track("\(name) - Inferred TC66 reboot because bootCount changed from \(bootCount) to \(snapshot.bootCount).")
606
        return true
607
    }
608

            
609
    private func updateChargerTypeLatch(observedIndex: UInt16, didDetectDeviceReset: Bool) {
610
        if didDetectDeviceReset, chargerTypeIndex != 0 {
611
            chargerTypeIndex = 0
612
        }
613

            
614
        guard supportsChargerDetection else { return }
615

            
616
        if chargerTypeIndex == 0 {
617
            chargerTypeIndex = observedIndex
618
            return
619
        }
620

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

            
625
    func dataDumpRequest() {
Bogdan Timofte authored 2 weeks ago
626
        guard operationalState >= .peripheralReady else {
627
            track("\(name) - Skip data request while state is \(operationalState)")
628
            return
629
        }
Bogdan Timofte authored 2 weeks ago
630
        if commandQueue.isEmpty {
631
            switch model {
632
            case .UM25C:
Bogdan Timofte authored 2 weeks ago
633
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
Bogdan Timofte authored 2 weeks ago
634
            case .UM34C:
Bogdan Timofte authored 2 weeks ago
635
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
Bogdan Timofte authored 2 weeks ago
636
            case .TC66C:
Bogdan Timofte authored 2 weeks ago
637
                btSerial.write(TC66Protocol.snapshotRequest, expectedResponseLength: 192)
Bogdan Timofte authored 2 weeks ago
638
            }
639
            dataDumpRequestTimestamp = Date()
640
            // track("\(name) - Request sent!")
641
        } else {
642
            track("Request delayed for: \(commandQueue.first!.hexEncodedStringValue)")
643
            btSerial.write( commandQueue.first! )
644
            commandQueue.removeFirst()
Bogdan Timofte authored 2 weeks ago
645
            scheduleDataDumpRequest(after: 1, reason: "queued command")
Bogdan Timofte authored 2 weeks ago
646
        }
647
    }
648

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

            
Bogdan Timofte authored 2 weeks ago
685
    private func apply(umSnapshot snapshot: UMSnapshot) {
Bogdan Timofte authored 2 weeks ago
686
        let didDetectDeviceReset = didUMDeviceReboot(with: snapshot, at: dataDumpRequestTimestamp)
Bogdan Timofte authored 2 weeks ago
687
        modelNumber = snapshot.modelNumber
688
        voltage = snapshot.voltage
689
        current = snapshot.current
690
        power = snapshot.power
691
        temperatureCelsius = snapshot.temperatureCelsius
692
        temperatureFahrenheit = snapshot.temperatureFahrenheit
693
        selectedDataGroup = snapshot.selectedDataGroup
694
        for (index, record) in snapshot.dataGroupRecords {
695
            dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
Bogdan Timofte authored 2 weeks ago
696
        }
Bogdan Timofte authored 2 weeks ago
697
        usbPlusVoltage = snapshot.usbPlusVoltage
698
        usbMinusVoltage = snapshot.usbMinusVoltage
Bogdan Timofte authored 2 weeks ago
699
        updateChargerTypeLatch(observedIndex: snapshot.chargerTypeIndex, didDetectDeviceReset: didDetectDeviceReset)
Bogdan Timofte authored 2 weeks ago
700
        recordedAH = snapshot.recordedAH
701
        recordedWH = snapshot.recordedWH
Bogdan Timofte authored 2 weeks ago
702

            
703
        if recordingThresholdTimestamp < dataDumpRequestTimestamp || !recordingThresholdLoadedFromDevice {
704
            recordingThresholdLoadedFromDevice = true
705
            if recordingTreshold != snapshot.recordingThreshold {
706
                isApplyingRecordingThresholdFromDevice = true
707
                recordingTreshold = snapshot.recordingThreshold
708
                isApplyingRecordingThresholdFromDevice = false
709
            }
710
        } else {
711
            track("\(name) - Skip updating recordingThreshold (changed after request).")
712
        }
Bogdan Timofte authored 2 weeks ago
713
        recordingDuration = snapshot.recordingDuration
714
        recording = snapshot.recording
715

            
Bogdan Timofte authored 2 weeks ago
716
        if screenTimeoutTimestamp < dataDumpRequestTimestamp {
Bogdan Timofte authored 2 weeks ago
717
            if screenTimeout != snapshot.screenTimeout {
718
                screenTimeout = snapshot.screenTimeout
Bogdan Timofte authored 2 weeks ago
719
            }
720
        } else {
721
            track("\(name) - Skip updating screenTimeout (changed after request).")
722
        }
723

            
724
        if screenBrightnessTimestamp < dataDumpRequestTimestamp {
Bogdan Timofte authored 2 weeks ago
725
            if screenBrightness != snapshot.screenBrightness {
726
                screenBrightness = snapshot.screenBrightness
Bogdan Timofte authored 2 weeks ago
727
            }
728
        } else {
729
            track("\(name) - Skip updating screenBrightness (changed after request).")
730
        }
731

            
Bogdan Timofte authored 2 weeks ago
732
        currentScreen = snapshot.currentScreen
733
        loadResistance = snapshot.loadResistance
Bogdan Timofte authored 2 weeks ago
734
    }
735

            
Bogdan Timofte authored 2 weeks ago
736
    private func apply(tc66Snapshot snapshot: TC66Snapshot) {
Bogdan Timofte authored 2 weeks ago
737
        let didDetectDeviceReset = didTC66DeviceReboot(with: snapshot)
Bogdan Timofte authored 2 weeks ago
738
        if hasSeenTC66Snapshot {
739
            inferTC66ActiveDataGroup(from: snapshot)
740
        } else {
741
            hasSeenTC66Snapshot = true
742
        }
Bogdan Timofte authored 2 weeks ago
743
        reportedModelName = snapshot.modelName
744
        firmwareVersion = snapshot.firmwareVersion
745
        serialNumber = snapshot.serialNumber
746
        bootCount = snapshot.bootCount
Bogdan Timofte authored 2 weeks ago
747
        updateChargerTypeLatch(observedIndex: 0, didDetectDeviceReset: didDetectDeviceReset)
Bogdan Timofte authored 2 weeks ago
748
        voltage = snapshot.voltage
749
        current = snapshot.current
750
        power = snapshot.power
751
        loadResistance = snapshot.loadResistance
752
        for (index, record) in snapshot.dataGroupRecords {
753
            dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
Bogdan Timofte authored 2 weeks ago
754
        }
Bogdan Timofte authored 2 weeks ago
755
        temperatureCelsius = snapshot.temperatureCelsius
756
        usbPlusVoltage = snapshot.usbPlusVoltage
757
        usbMinusVoltage = snapshot.usbMinusVoltage
Bogdan Timofte authored 2 weeks ago
758
    }
Bogdan Timofte authored 2 weeks ago
759

            
760
    private func inferTC66ActiveDataGroup(from snapshot: TC66Snapshot) {
761
        let candidate = snapshot.dataGroupRecords.compactMap { entry -> (UInt8, Double)? in
762
            let index = entry.key
763
            let record = entry.value
764
            guard let previous = dataGroupRecords[index] else { return nil }
765
            let deltaAH = max(record.ah - previous.ah, 0)
766
            let deltaWH = max(record.wh - previous.wh, 0)
767
            let score = deltaAH + deltaWH
768
            guard score > 0 else { return nil }
769
            return (UInt8(index), score)
770
        }
771
        .max { lhs, rhs in lhs.1 < rhs.1 }
772

            
773
        if let candidate {
774
            selectedDataGroup = candidate.0
775
            hasObservedActiveDataGroup = true
776
        }
777
    }
778

            
779
    private func updateChargeRecord(at timestamp: Date) {
780
        switch chargeRecordState {
781
        case .waitingForStart:
782
            guard current > chargeRecordStopThreshold else { return }
783
            chargeRecordState = .active
784
            chargeRecordStartTimestamp = timestamp
785
            chargeRecordEndTimestamp = timestamp
786
            chargeRecordLastTimestamp = timestamp
787
            chargeRecordLastCurrent = current
788
            chargeRecordLastPower = power
789
        case .active:
790
            if let lastTimestamp = chargeRecordLastTimestamp {
791
                let deltaSeconds = max(timestamp.timeIntervalSince(lastTimestamp), 0)
792
                chargeRecordAH += chargeRecordLastCurrent * deltaSeconds / 3600
793
                chargeRecordWH += chargeRecordLastPower * deltaSeconds / 3600
794
                chargeRecordDuration += deltaSeconds
795
            }
796
            chargeRecordEndTimestamp = timestamp
797
            chargeRecordLastTimestamp = timestamp
798
            chargeRecordLastCurrent = current
799
            chargeRecordLastPower = power
800
            if current <= chargeRecordStopThreshold {
801
                chargeRecordState = .completed
802
            }
803
        case .completed:
804
            break
805
        }
806
    }
807

            
808
    func resetChargeRecord() {
809
        chargeRecordAH = 0
810
        chargeRecordWH = 0
811
        chargeRecordDuration = 0
812
        chargeRecordState = .waitingForStart
813
        chargeRecordStartTimestamp = nil
814
        chargeRecordEndTimestamp = nil
815
        chargeRecordLastTimestamp = nil
816
        chargeRecordLastCurrent = 0
817
        chargeRecordLastPower = 0
818
    }
819

            
820
    func resetChargeRecordGraph() {
821
        let cutoff = Date()
822
        resetChargeRecord()
823
        measurements.trim(before: cutoff)
824
    }
Bogdan Timofte authored 2 weeks ago
825

            
826
    func nextScreen() {
827
        switch model {
828
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
829
            commandQueue.append(UMProtocol.nextScreen)
Bogdan Timofte authored 2 weeks ago
830
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
831
            commandQueue.append(UMProtocol.nextScreen)
Bogdan Timofte authored 2 weeks ago
832
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
833
            commandQueue.append(TC66Protocol.nextPage)
Bogdan Timofte authored 2 weeks ago
834
        }
835
    }
836

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

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

            
859
    func clear() {
Bogdan Timofte authored 2 weeks ago
860
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
861
        noteInitiatedVolatileMemoryResetIfNeeded(for: selectedDataGroup)
Bogdan Timofte authored 2 weeks ago
862
        commandQueue.append(UMProtocol.clearCurrentGroup)
Bogdan Timofte authored 2 weeks ago
863
    }
864

            
865
    func clear(group id: UInt8) {
Bogdan Timofte authored 2 weeks ago
866
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
867
        commandQueue.append(UMProtocol.selectDataGroup(id))
Bogdan Timofte authored 2 weeks ago
868
        noteInitiatedVolatileMemoryResetIfNeeded(for: id)
869
        commandQueue.append(UMProtocol.clearCurrentGroup)
Bogdan Timofte authored 2 weeks ago
870
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
Bogdan Timofte authored 2 weeks ago
871
    }
872

            
873
    func selectDataGroup ( id: UInt8) {
Bogdan Timofte authored 2 weeks ago
874
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
875
        track("\(name) - \(id)")
876
        selectedDataGroup = id
Bogdan Timofte authored 2 weeks ago
877
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
Bogdan Timofte authored 2 weeks ago
878
    }
879

            
880
    private func setSceeenBrightness ( to value: UInt8) {
881
        track("\(name) - \(value)")
Bogdan Timofte authored 2 weeks ago
882
        guard supportsUMSettings else { return }
Bogdan Timofte authored 2 weeks ago
883
        commandQueue.append(UMProtocol.setScreenBrightness(value))
Bogdan Timofte authored 2 weeks ago
884
    }
885
    private func setScreenSaverTimeout ( to value: UInt8) {
886
        track("\(name) - \(value)")
Bogdan Timofte authored 2 weeks ago
887
        guard supportsUMSettings else { return }
Bogdan Timofte authored 2 weeks ago
888
        commandQueue.append(UMProtocol.setScreenSaverTimeout(value))
Bogdan Timofte authored 2 weeks ago
889
    }
890
    func setrecordingTreshold ( to value: UInt8) {
Bogdan Timofte authored 2 weeks ago
891
        guard supportsRecordingThreshold else { return }
Bogdan Timofte authored 2 weeks ago
892
        commandQueue.append(UMProtocol.setRecordingThreshold(value))
Bogdan Timofte authored 2 weeks ago
893
    }
894

            
895
    /**
896
     Connect to meter.
897
     1. It calls BluetoothSerial.connect
898
     */
899
    func connect() {
900
        enableAutoConnect = true
901
        btSerial.connect()
902
    }
903

            
904
    /**
905
     Disconnect from meter.
906
        It calls BluetoothSerial.disconnect
907
     */
908
    func disconnect() {
909
        enableAutoConnect = false
910
        btSerial.disconnect()
911
    }
912
}
913

            
914
extension Meter : SerialPortDelegate {
915

            
916
    func opertionalStateChanged(to serialPortOperationalState: BluetoothSerial.OperationalState) {
Bogdan Timofte authored 2 weeks ago
917
        let applyStateChange = {
918
            self.lastSeen = Date()
919
            switch serialPortOperationalState {
920
            case .peripheralNotConnected:
921
                self.operationalState = .peripheralNotConnected
922
            case .peripheralConnectionPending:
923
                self.operationalState = .peripheralConnectionPending
924
            case .peripheralConnected:
925
                self.operationalState = .peripheralConnected
926
            case .peripheralReady:
927
                self.operationalState = .peripheralReady
928
            }
929
        }
930

            
931
        if Thread.isMainThread {
932
            applyStateChange()
933
        } else {
934
            DispatchQueue.main.async(execute: applyStateChange)
Bogdan Timofte authored 2 weeks ago
935
        }
936
    }
937

            
938
    func didReceiveData(_ data: Data) {
Bogdan Timofte authored 2 weeks ago
939
        let applyData = {
940
            self.lastSeen = Date()
941
            self.operationalState = .comunicating
942
            self.parseData(from: data)
943
        }
944

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