Newer Older
819 lines | 27.074kb
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 {
91
            switch operationalState {
92
            case .notPresent:
93
                break
94
            case .peripheralNotConnected:
95
                if enableAutoConnect {
96
                    track("\(name) - Reconnecting...")
97
                    btSerial.connect()
98
                }
99
            case .peripheralConnectionPending:
100
                break
101
            case .peripheralConnected:
102
                break
103
            case .peripheralReady:
104
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // Change `2.0` to the desired number of seconds.
105
                    self.dataDumpRequest()
106
                }
107
            case .comunicating:
108
                break
109
            case .dataIsAvailable:
110
                break
111
            }
112
        }
113
    }
114

            
115
    static func operationalColor(for state: OperationalState) -> Color {
116
        switch state {
117
        case .notPresent:
118
            return .red
119
        case .peripheralNotConnected:
120
            return .blue
121
        case .peripheralConnectionPending:
122
            return .yellow
123
        case .peripheralConnected:
124
            return .yellow
125
        case .peripheralReady:
126
            return .orange
127
        case .comunicating:
128
            return .orange
129
        case .dataIsAvailable:
130
            return .green
131
        }
132
    }
133

            
134
    private var wdTimer: Timer?
135

            
136
    @Published var lastSeen = Date() {
137
        didSet {
138
            wdTimer?.invalidate()
139
            if operationalState == .peripheralNotConnected {
140
                wdTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false, block: {_ in
141
                    track("\(self.name) - Lost advertisments...")
142
                    self.operationalState = .notPresent
143
                })
144
            } else if operationalState == .notPresent {
145
               operationalState = .peripheralNotConnected
146
            }
147
        }
148
    }
149

            
150
    var uuid: UUID
151
    var model: Model
152
    var modelString: String
153

            
154
    var name: String {
155
        didSet {
156
            appData.meterNames[btSerial.macAddress.description] = name
157
        }
158
    }
159

            
160
    var color : Color {
161
        get {
Bogdan Timofte authored 2 weeks ago
162
            return model.color
Bogdan Timofte authored 2 weeks ago
163
        }
164
    }
165

            
Bogdan Timofte authored 2 weeks ago
166
    var capabilities: MeterCapabilities {
167
        model.capabilities
168
    }
169

            
Bogdan Timofte authored 2 weeks ago
170
    var availableDataGroupIDs: [UInt8] {
Bogdan Timofte authored 2 weeks ago
171
        capabilities.availableDataGroupIDs
Bogdan Timofte authored 2 weeks ago
172
    }
173

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

            
Bogdan Timofte authored 2 weeks ago
178
    var supportsRecordingView: Bool {
179
        capabilities.supportsRecordingView
180
    }
181

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

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

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

            
194
    var showsDataGroupEnergy: Bool {
195
        capabilities.showsDataGroupEnergy
196
    }
197

            
198
    var highlightsActiveDataGroup: Bool {
199
        if model == .TC66C {
200
            return hasObservedActiveDataGroup
201
        }
202
        return capabilities.highlightsActiveDataGroup
203
    }
204

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

            
Bogdan Timofte authored 2 weeks ago
209
    var supportsManualTemperatureUnitSelection: Bool {
210
        model == .TC66C
211
    }
212

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

            
Bogdan Timofte authored 2 weeks ago
217
    var dataGroupsTitle: String {
218
        capabilities.dataGroupsTitle
219
    }
220

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

            
Bogdan Timofte authored 2 weeks ago
225
    var chargerTypeDescription: String {
226
        capabilities.chargerTypeDescription(for: chargerTypeIndex)
Bogdan Timofte authored 2 weeks ago
227
    }
228

            
Bogdan Timofte authored 2 weeks ago
229
    var temperatureUnitDescription: String {
230
        if supportsManualTemperatureUnitSelection {
Bogdan Timofte authored 2 weeks ago
231
            return "Device-defined"
Bogdan Timofte authored 2 weeks ago
232
        }
Bogdan Timofte authored 2 weeks ago
233
        return systemTemperatureUnitPreference.localeTitle
Bogdan Timofte authored 2 weeks ago
234
    }
235

            
236
    var primaryTemperatureDescription: String {
Bogdan Timofte authored 2 weeks ago
237
        let value = displayedTemperatureValue.format(decimalDigits: 0)
Bogdan Timofte authored 2 weeks ago
238
        if supportsManualTemperatureUnitSelection {
Bogdan Timofte authored 2 weeks ago
239
            return "\(value)°"
Bogdan Timofte authored 2 weeks ago
240
        }
Bogdan Timofte authored 2 weeks ago
241
        return "\(value)\(systemTemperatureUnitPreference.symbol)"
Bogdan Timofte authored 2 weeks ago
242
    }
243

            
244
    var secondaryTemperatureDescription: String? {
Bogdan Timofte authored 2 weeks ago
245
        nil
246
    }
247

            
248
    var displayedTemperatureValue: Double {
249
        if supportsManualTemperatureUnitSelection {
250
            return temperatureCelsius
251
        }
252
        switch systemTemperatureUnitPreference {
253
        case .celsius:
254
            return displayedTemperatureCelsius
255
        case .fahrenheit:
256
            return displayedTemperatureFahrenheit
257
        }
258
    }
259

            
260
    private var displayedTemperatureCelsius: Double {
261
        if supportsManualTemperatureUnitSelection {
262
            switch tc66TemperatureUnitPreference {
263
            case .celsius:
264
                return temperatureCelsius
265
            case .fahrenheit:
266
                return (temperatureCelsius - 32) * 5 / 9
267
            }
268
        }
269
        return temperatureCelsius
270
    }
271

            
272
    private var displayedTemperatureFahrenheit: Double {
273
        if supportsManualTemperatureUnitSelection {
274
            switch tc66TemperatureUnitPreference {
275
            case .celsius:
276
                return (temperatureCelsius * 9 / 5) + 32
277
            case .fahrenheit:
278
                return temperatureCelsius
279
            }
280
        }
281
        if supportsFahrenheit, temperatureFahrenheit.isFinite {
282
            return temperatureFahrenheit
283
        }
284
        return (temperatureCelsius * 9 / 5) + 32
285
    }
286

            
287
    private var systemTemperatureUnitPreference: TemperatureUnitPreference {
288
        let locale = Locale.autoupdatingCurrent
289
        if #available(iOS 16.0, *) {
290
            switch locale.measurementSystem {
291
            case .us:
292
                return .fahrenheit
293
            default:
294
                return .celsius
295
            }
296
        }
297

            
298
        let regionCode = locale.regionCode ?? ""
299
        let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
300
        return fahrenheitRegions.contains(regionCode) ? .fahrenheit : .celsius
Bogdan Timofte authored 2 weeks ago
301
    }
302

            
Bogdan Timofte authored 2 weeks ago
303
    var currentScreenDescription: String {
Bogdan Timofte authored 2 weeks ago
304
        guard reportsCurrentScreenIndex else {
305
            return "Page Controls"
306
        }
Bogdan Timofte authored 2 weeks ago
307
        if let label = capabilities.screenDescription(for: currentScreen) {
308
            return "Screen \(currentScreen): \(label)"
309
        }
310
        return "Screen \(currentScreen)"
311
    }
312

            
Bogdan Timofte authored 2 weeks ago
313
    var deviceModelSummary: String {
314
        let baseName = reportedModelName.isEmpty ? modelString : reportedModelName
315
        if modelNumber != 0 {
316
            return "\(baseName) (\(modelNumber))"
317
        }
318
        return baseName
319
    }
320

            
Bogdan Timofte authored 2 weeks ago
321
    var recordingDurationDescription: String {
322
        let totalSeconds = Int(recordingDuration)
323
        let hours = totalSeconds / 3600
324
        let minutes = (totalSeconds % 3600) / 60
325
        let seconds = totalSeconds % 60
326

            
327
        if hours > 0 {
328
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
329
        }
330
        return String(format: "%02d:%02d", minutes, seconds)
331
    }
332

            
Bogdan Timofte authored 2 weeks ago
333
    var chargeRecordDurationDescription: String {
334
        let totalSeconds = Int(chargeRecordDuration)
335
        let hours = totalSeconds / 3600
336
        let minutes = (totalSeconds % 3600) / 60
337
        let seconds = totalSeconds % 60
338

            
339
        if hours > 0 {
340
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
341
        }
342
        return String(format: "%02d:%02d", minutes, seconds)
343
    }
344

            
345
    var chargeRecordTimeRange: ClosedRange<Date>? {
346
        guard let start = chargeRecordStartTimestamp else { return nil }
347
        let end = chargeRecordEndTimestamp ?? chargeRecordLastTimestamp
348
        guard let end else { return nil }
349
        return start...end
350
    }
351

            
352
    var chargeRecordStatusText: String {
353
        switch chargeRecordState {
354
        case .waitingForStart:
355
            return "Waiting"
356
        case .active:
357
            return "Active"
358
        case .completed:
359
            return "Completed"
360
        }
361
    }
362

            
363
    var chargeRecordStatusColor: Color {
364
        switch chargeRecordState {
365
        case .waitingForStart:
366
            return .secondary
367
        case .active:
368
            return .red
369
        case .completed:
370
            return .green
371
        }
372
    }
373

            
Bogdan Timofte authored 2 weeks ago
374
    var dataGroupsHint: String? {
Bogdan Timofte authored 2 weeks ago
375
        if model == .TC66C {
376
            if hasObservedActiveDataGroup {
377
                return "The active memory is inferred from the totals that are currently increasing."
378
            }
379
            return "The device exposes two read-only memories. The active memory is inferred once one total starts increasing."
380
        }
381
        return capabilities.dataGroupsHint
382
    }
383

            
384
    func dataGroupLabel(for id: UInt8) -> String {
385
        supportsDataGroupCommands ? "\(id)" : "M\(Int(id) + 1)"
Bogdan Timofte authored 2 weeks ago
386
    }
387

            
388
    var recordingThresholdHint: String? {
389
        capabilities.recordingThresholdHint
390
    }
391

            
Bogdan Timofte authored 2 weeks ago
392
    @Published var btSerial: BluetoothSerial
393

            
394
    @Published var measurements = Measurements()
395

            
396
    private var commandQueue: [Data] = []
397
    private var dataDumpRequestTimestamp = Date()
398

            
399
    class DataGroupRecord {
400
        @Published var ah: Double
401
        @Published var wh: Double
402
        init(ah: Double, wh: Double) {
403
            self.ah = ah
404
            self.wh = wh
405
        }
406
    }
407
    @Published var selectedDataGroup: UInt8 = 0
408
    @Published var dataGroupRecords: [Int : DataGroupRecord] = [:]
Bogdan Timofte authored 2 weeks ago
409
    @Published var chargeRecordAH: Double = 0
410
    @Published var chargeRecordWH: Double = 0
411
    @Published var chargeRecordDuration: TimeInterval = 0
412
    @Published var chargeRecordStopThreshold: Double = 0.05
413
    @Published var tc66TemperatureUnitPreference: TemperatureUnitPreference = .celsius {
414
        didSet {
415
            guard supportsManualTemperatureUnitSelection else { return }
416
            guard oldValue != tc66TemperatureUnitPreference else { return }
417
            var settings = appData.tc66TemperatureUnits
418
            settings[btSerial.macAddress.description] = tc66TemperatureUnitPreference.rawValue
419
            appData.tc66TemperatureUnits = settings
420
        }
421
    }
Bogdan Timofte authored 2 weeks ago
422

            
423
    @Published var screenBrightness: Int = -1 {
424
        didSet {
425
            if oldValue != screenBrightness {
426
                screenBrightnessTimestamp = Date()
427
                if oldValue != -1 {
428
                    setSceeenBrightness(to: UInt8(screenBrightness))
429
                }
430
            }
431
        }
432
    }
433
    private var screenBrightnessTimestamp = Date()
434

            
435
    @Published var screenTimeout: Int = -1 {
436
        didSet {
437
            if oldValue != screenTimeout {
438
                screenTimeoutTimestamp = Date()
439
                if oldValue != -1 {
440
                    setScreenSaverTimeout(to: UInt8(screenTimeout))
441
                }
442
            }
443
        }
444
    }
445
    private var screenTimeoutTimestamp = Date()
446

            
447
    @Published var voltage: Double = 0
448
    @Published var current: Double = 0
449
    @Published var power: Double = 0
450
    @Published var temperatureCelsius: Double = 0
451
    @Published var temperatureFahrenheit: Double = 0
452
    @Published var usbPlusVoltage: Double = 0
453
    @Published var usbMinusVoltage: Double = 0
454
    @Published var recordedAH: Double = 0
455
    @Published var recordedWH: Double = 0
456
    @Published var recording: Bool = false
Bogdan Timofte authored 2 weeks ago
457
    @Published var recordingTreshold: Double = 0 {
Bogdan Timofte authored 2 weeks ago
458
        didSet {
Bogdan Timofte authored 2 weeks ago
459
            guard recordingTreshold != oldValue else { return }
460
            if isApplyingRecordingThresholdFromDevice {
461
                return
Bogdan Timofte authored 2 weeks ago
462
            }
Bogdan Timofte authored 2 weeks ago
463
            recordingThresholdTimestamp = Date()
464
            guard recordingThresholdLoadedFromDevice else { return }
465
            setrecordingTreshold(to: (recordingTreshold * 100).uInt8Value)
Bogdan Timofte authored 2 weeks ago
466
        }
Bogdan Timofte authored 2 weeks ago
467
    }
Bogdan Timofte authored 2 weeks ago
468
    @Published var currentScreen: UInt16 = 0
469
    @Published var recordingDuration: UInt32 = 0
470
    @Published var loadResistance: Double = 0
471
    @Published var modelNumber: UInt16 = 0
472
    @Published var chargerTypeIndex: UInt16 = 0
Bogdan Timofte authored 2 weeks ago
473
    @Published var reportedModelName: String = ""
474
    @Published var firmwareVersion: String = ""
475
    @Published var serialNumber: UInt32 = 0
476
    @Published var bootCount: UInt32 = 0
Bogdan Timofte authored 2 weeks ago
477
    private var enableAutoConnect: Bool = false
Bogdan Timofte authored 2 weeks ago
478
    private var recordingThresholdTimestamp = Date()
479
    private var recordingThresholdLoadedFromDevice = false
480
    private var isApplyingRecordingThresholdFromDevice = false
Bogdan Timofte authored 2 weeks ago
481
    @Published private(set) var chargeRecordState = ChargeRecordState.waitingForStart
482
    private var chargeRecordStartTimestamp: Date?
483
    private var chargeRecordEndTimestamp: Date?
484
    private var chargeRecordLastTimestamp: Date?
485
    private var chargeRecordLastCurrent: Double = 0
486
    private var chargeRecordLastPower: Double = 0
487
    private var hasObservedActiveDataGroup = false
488
    private var hasSeenTC66Snapshot = false
Bogdan Timofte authored 2 weeks ago
489

            
490
    init ( model: Model, with serialPort: BluetoothSerial ) {
491
        uuid = serialPort.peripheral.identifier
492
        //dataStore.meterUUIDS.append(serialPort.peripheral.identifier)
493
        modelString = serialPort.peripheral.name!
494
        self.model = model
495
        btSerial = serialPort
496
        name = appData.meterNames[serialPort.macAddress.description] ?? serialPort.macAddress.description
497
        super.init()
498
        btSerial.delegate = self
Bogdan Timofte authored 2 weeks ago
499
        reloadTemperatureUnitPreference()
Bogdan Timofte authored 2 weeks ago
500
        //name = dataStore.meterNames[macAddress.description] ?? peripheral.name!
501
        for index in stride(from: 0, through: 9, by: 1) {
502
            dataGroupRecords[index] = DataGroupRecord(ah:0, wh: 0)
503
        }
504
    }
Bogdan Timofte authored 2 weeks ago
505

            
506
    func reloadTemperatureUnitPreference() {
507
        guard supportsManualTemperatureUnitSelection else { return }
508
        let rawValue = appData.tc66TemperatureUnits[btSerial.macAddress.description] ?? TemperatureUnitPreference.celsius.rawValue
509
        let persistedPreference = TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
510
        if tc66TemperatureUnitPreference != persistedPreference {
511
            tc66TemperatureUnitPreference = persistedPreference
512
        }
513
    }
Bogdan Timofte authored 2 weeks ago
514

            
515
    func dataDumpRequest() {
516
        if commandQueue.isEmpty {
517
            switch model {
518
            case .UM25C:
Bogdan Timofte authored 2 weeks ago
519
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
Bogdan Timofte authored 2 weeks ago
520
            case .UM34C:
Bogdan Timofte authored 2 weeks ago
521
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
Bogdan Timofte authored 2 weeks ago
522
            case .TC66C:
Bogdan Timofte authored 2 weeks ago
523
                btSerial.write(TC66Protocol.snapshotRequest, expectedResponseLength: 192)
Bogdan Timofte authored 2 weeks ago
524
            }
525
            dataDumpRequestTimestamp = Date()
526
            // track("\(name) - Request sent!")
527
        } else {
528
            track("Request delayed for: \(commandQueue.first!.hexEncodedStringValue)")
529
            btSerial.write( commandQueue.first! )
530
            commandQueue.removeFirst()
531
            DispatchQueue.main.asyncAfter( deadline: .now() + 1 )  {
532
                self.dataDumpRequest()
533
            }
534
        }
535
    }
536

            
537
    /**
538
     received data parser
539
     - parameter buffer cotains response for data dump request
540
     - Decription metod for TC66C AES ECB response  found [here](https:github.com/krzyzanowskim/CryptoSwift/issues/693)
541
     */
542
    func parseData ( from buffer: Data) {
543
        //track("\(name)")
544
        switch model {
545
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
546
            do {
547
                apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
548
            } catch {
549
                track("\(name) - Error: \(error)")
550
            }
Bogdan Timofte authored 2 weeks ago
551
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
552
            do {
553
                apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
554
            } catch {
555
                track("\(name) - Error: \(error)")
556
            }
Bogdan Timofte authored 2 weeks ago
557
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
558
            do {
559
                apply(tc66Snapshot: try TC66Protocol.parseSnapshot(from: buffer))
560
            } catch {
561
                track("\(name) - Error: \(error)")
562
            }
Bogdan Timofte authored 2 weeks ago
563
        }
Bogdan Timofte authored 2 weeks ago
564
        updateChargeRecord(at: dataDumpRequestTimestamp)
Bogdan Timofte authored 2 weeks ago
565
        measurements.addValues(timestamp: dataDumpRequestTimestamp, power: power, voltage: voltage, current: current)
566
//        DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
567
//            //track("\(name) - Scheduled new request.")
568
//        }
569
        operationalState = .dataIsAvailable
570
        dataDumpRequest()
571
    }
572

            
Bogdan Timofte authored 2 weeks ago
573
    private func apply(umSnapshot snapshot: UMSnapshot) {
574
        modelNumber = snapshot.modelNumber
575
        voltage = snapshot.voltage
576
        current = snapshot.current
577
        power = snapshot.power
578
        temperatureCelsius = snapshot.temperatureCelsius
579
        temperatureFahrenheit = snapshot.temperatureFahrenheit
580
        selectedDataGroup = snapshot.selectedDataGroup
581
        for (index, record) in snapshot.dataGroupRecords {
582
            dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
Bogdan Timofte authored 2 weeks ago
583
        }
Bogdan Timofte authored 2 weeks ago
584
        usbPlusVoltage = snapshot.usbPlusVoltage
585
        usbMinusVoltage = snapshot.usbMinusVoltage
586
        chargerTypeIndex = snapshot.chargerTypeIndex
587
        recordedAH = snapshot.recordedAH
588
        recordedWH = snapshot.recordedWH
Bogdan Timofte authored 2 weeks ago
589

            
590
        if recordingThresholdTimestamp < dataDumpRequestTimestamp || !recordingThresholdLoadedFromDevice {
591
            recordingThresholdLoadedFromDevice = true
592
            if recordingTreshold != snapshot.recordingThreshold {
593
                isApplyingRecordingThresholdFromDevice = true
594
                recordingTreshold = snapshot.recordingThreshold
595
                isApplyingRecordingThresholdFromDevice = false
596
            }
597
        } else {
598
            track("\(name) - Skip updating recordingThreshold (changed after request).")
599
        }
Bogdan Timofte authored 2 weeks ago
600
        recordingDuration = snapshot.recordingDuration
601
        recording = snapshot.recording
602

            
Bogdan Timofte authored 2 weeks ago
603
        if screenTimeoutTimestamp < dataDumpRequestTimestamp {
Bogdan Timofte authored 2 weeks ago
604
            if screenTimeout != snapshot.screenTimeout {
605
                screenTimeout = snapshot.screenTimeout
Bogdan Timofte authored 2 weeks ago
606
            }
607
        } else {
608
            track("\(name) - Skip updating screenTimeout (changed after request).")
609
        }
610

            
611
        if screenBrightnessTimestamp < dataDumpRequestTimestamp {
Bogdan Timofte authored 2 weeks ago
612
            if screenBrightness != snapshot.screenBrightness {
613
                screenBrightness = snapshot.screenBrightness
Bogdan Timofte authored 2 weeks ago
614
            }
615
        } else {
616
            track("\(name) - Skip updating screenBrightness (changed after request).")
617
        }
618

            
Bogdan Timofte authored 2 weeks ago
619
        currentScreen = snapshot.currentScreen
620
        loadResistance = snapshot.loadResistance
Bogdan Timofte authored 2 weeks ago
621
    }
622

            
Bogdan Timofte authored 2 weeks ago
623
    private func apply(tc66Snapshot snapshot: TC66Snapshot) {
Bogdan Timofte authored 2 weeks ago
624
        if hasSeenTC66Snapshot {
625
            inferTC66ActiveDataGroup(from: snapshot)
626
        } else {
627
            hasSeenTC66Snapshot = true
628
        }
Bogdan Timofte authored 2 weeks ago
629
        reportedModelName = snapshot.modelName
630
        firmwareVersion = snapshot.firmwareVersion
631
        serialNumber = snapshot.serialNumber
632
        bootCount = snapshot.bootCount
Bogdan Timofte authored 2 weeks ago
633
        voltage = snapshot.voltage
634
        current = snapshot.current
635
        power = snapshot.power
636
        loadResistance = snapshot.loadResistance
637
        for (index, record) in snapshot.dataGroupRecords {
638
            dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
Bogdan Timofte authored 2 weeks ago
639
        }
Bogdan Timofte authored 2 weeks ago
640
        temperatureCelsius = snapshot.temperatureCelsius
641
        usbPlusVoltage = snapshot.usbPlusVoltage
642
        usbMinusVoltage = snapshot.usbMinusVoltage
Bogdan Timofte authored 2 weeks ago
643
    }
Bogdan Timofte authored 2 weeks ago
644

            
645
    private func inferTC66ActiveDataGroup(from snapshot: TC66Snapshot) {
646
        let candidate = snapshot.dataGroupRecords.compactMap { entry -> (UInt8, Double)? in
647
            let index = entry.key
648
            let record = entry.value
649
            guard let previous = dataGroupRecords[index] else { return nil }
650
            let deltaAH = max(record.ah - previous.ah, 0)
651
            let deltaWH = max(record.wh - previous.wh, 0)
652
            let score = deltaAH + deltaWH
653
            guard score > 0 else { return nil }
654
            return (UInt8(index), score)
655
        }
656
        .max { lhs, rhs in lhs.1 < rhs.1 }
657

            
658
        if let candidate {
659
            selectedDataGroup = candidate.0
660
            hasObservedActiveDataGroup = true
661
        }
662
    }
663

            
664
    private func updateChargeRecord(at timestamp: Date) {
665
        switch chargeRecordState {
666
        case .waitingForStart:
667
            guard current > chargeRecordStopThreshold else { return }
668
            chargeRecordState = .active
669
            chargeRecordStartTimestamp = timestamp
670
            chargeRecordEndTimestamp = timestamp
671
            chargeRecordLastTimestamp = timestamp
672
            chargeRecordLastCurrent = current
673
            chargeRecordLastPower = power
674
        case .active:
675
            if let lastTimestamp = chargeRecordLastTimestamp {
676
                let deltaSeconds = max(timestamp.timeIntervalSince(lastTimestamp), 0)
677
                chargeRecordAH += chargeRecordLastCurrent * deltaSeconds / 3600
678
                chargeRecordWH += chargeRecordLastPower * deltaSeconds / 3600
679
                chargeRecordDuration += deltaSeconds
680
            }
681
            chargeRecordEndTimestamp = timestamp
682
            chargeRecordLastTimestamp = timestamp
683
            chargeRecordLastCurrent = current
684
            chargeRecordLastPower = power
685
            if current <= chargeRecordStopThreshold {
686
                chargeRecordState = .completed
687
            }
688
        case .completed:
689
            break
690
        }
691
    }
692

            
693
    func resetChargeRecord() {
694
        chargeRecordAH = 0
695
        chargeRecordWH = 0
696
        chargeRecordDuration = 0
697
        chargeRecordState = .waitingForStart
698
        chargeRecordStartTimestamp = nil
699
        chargeRecordEndTimestamp = nil
700
        chargeRecordLastTimestamp = nil
701
        chargeRecordLastCurrent = 0
702
        chargeRecordLastPower = 0
703
    }
704

            
705
    func resetChargeRecordGraph() {
706
        let cutoff = Date()
707
        resetChargeRecord()
708
        measurements.trim(before: cutoff)
709
    }
Bogdan Timofte authored 2 weeks ago
710

            
711
    func nextScreen() {
712
        switch model {
713
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
714
            commandQueue.append(UMProtocol.nextScreen)
Bogdan Timofte authored 2 weeks ago
715
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
716
            commandQueue.append(UMProtocol.nextScreen)
Bogdan Timofte authored 2 weeks ago
717
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
718
            commandQueue.append(TC66Protocol.nextPage)
Bogdan Timofte authored 2 weeks ago
719
        }
720
    }
721

            
722
    func rotateScreen() {
723
        switch model {
724
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
725
            commandQueue.append(UMProtocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
726
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
727
            commandQueue.append(UMProtocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
728
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
729
            commandQueue.append(TC66Protocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
730
        }
731
    }
732

            
733
    func previousScreen() {
734
        switch model {
735
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
736
            commandQueue.append(UMProtocol.previousScreen)
Bogdan Timofte authored 2 weeks ago
737
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
738
            commandQueue.append(UMProtocol.previousScreen)
Bogdan Timofte authored 2 weeks ago
739
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
740
            commandQueue.append(TC66Protocol.previousPage)
Bogdan Timofte authored 2 weeks ago
741
        }
742
    }
743

            
744
    func clear() {
Bogdan Timofte authored 2 weeks ago
745
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
746
        commandQueue.append(UMProtocol.clearCurrentGroup)
Bogdan Timofte authored 2 weeks ago
747
    }
748

            
749
    func clear(group id: UInt8) {
Bogdan Timofte authored 2 weeks ago
750
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
751
        commandQueue.append(UMProtocol.selectDataGroup(id))
Bogdan Timofte authored 2 weeks ago
752
        clear()
Bogdan Timofte authored 2 weeks ago
753
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
Bogdan Timofte authored 2 weeks ago
754
    }
755

            
756
    func selectDataGroup ( id: UInt8) {
Bogdan Timofte authored 2 weeks ago
757
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
758
        track("\(name) - \(id)")
759
        selectedDataGroup = id
Bogdan Timofte authored 2 weeks ago
760
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
Bogdan Timofte authored 2 weeks ago
761
    }
762

            
763
    private func setSceeenBrightness ( to value: UInt8) {
764
        track("\(name) - \(value)")
Bogdan Timofte authored 2 weeks ago
765
        guard supportsUMSettings else { return }
Bogdan Timofte authored 2 weeks ago
766
        commandQueue.append(UMProtocol.setScreenBrightness(value))
Bogdan Timofte authored 2 weeks ago
767
    }
768
    private func setScreenSaverTimeout ( to value: UInt8) {
769
        track("\(name) - \(value)")
Bogdan Timofte authored 2 weeks ago
770
        guard supportsUMSettings else { return }
Bogdan Timofte authored 2 weeks ago
771
        commandQueue.append(UMProtocol.setScreenSaverTimeout(value))
Bogdan Timofte authored 2 weeks ago
772
    }
773
    func setrecordingTreshold ( to value: UInt8) {
Bogdan Timofte authored 2 weeks ago
774
        guard supportsRecordingThreshold else { return }
Bogdan Timofte authored 2 weeks ago
775
        commandQueue.append(UMProtocol.setRecordingThreshold(value))
Bogdan Timofte authored 2 weeks ago
776
    }
777

            
778
    /**
779
     Connect to meter.
780
     1. It calls BluetoothSerial.connect
781
     */
782
    func connect() {
783
        enableAutoConnect = true
784
        btSerial.connect()
785
    }
786

            
787
    /**
788
     Disconnect from meter.
789
        It calls BluetoothSerial.disconnect
790
     */
791
    func disconnect() {
792
        enableAutoConnect = false
793
        btSerial.disconnect()
794
    }
795
}
796

            
797
extension Meter : SerialPortDelegate {
798

            
799
    func opertionalStateChanged(to serialPortOperationalState: BluetoothSerial.OperationalState) {
800
        lastSeen = Date()
801
        //track("\(name) - \(serialPortOperationalState)")
802
        switch serialPortOperationalState {
803
        case .peripheralNotConnected:
804
            operationalState = .peripheralNotConnected
805
        case .peripheralConnectionPending:
806
            operationalState = .peripheralConnectionPending
807
        case .peripheralConnected:
808
            operationalState = .peripheralConnected
809
        case .peripheralReady:
810
            operationalState = .peripheralReady
811
        }
812
    }
813

            
814
    func didReceiveData(_ data: Data) {
815
        lastSeen = Date()
816
        operationalState = .comunicating
817
        parseData(from: data)
818
    }
819
}