Newer Older
757 lines | 25.255kb
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

            
56
enum ChargeRecordState {
57
    case waitingForStart
58
    case active
59
    case completed
60
}
61

            
Bogdan Timofte authored 2 weeks ago
62
class Meter : NSObject, ObservableObject, Identifiable {
63

            
64
    enum OperationalState: Int, Comparable {
65
        case notPresent
66
        case peripheralNotConnected
67
        case peripheralConnectionPending
68
        case peripheralConnected
69
        case peripheralReady
70
        case comunicating
71
        case dataIsAvailable
72

            
73
        static func < (lhs: OperationalState, rhs: OperationalState) -> Bool {
74
            return lhs.rawValue < rhs.rawValue
75
        }
76
    }
77

            
78
    @Published var operationalState = OperationalState.peripheralNotConnected {
79
        didSet {
80
            switch operationalState {
81
            case .notPresent:
82
                break
83
            case .peripheralNotConnected:
84
                if enableAutoConnect {
85
                    track("\(name) - Reconnecting...")
86
                    btSerial.connect()
87
                }
88
            case .peripheralConnectionPending:
89
                break
90
            case .peripheralConnected:
91
                break
92
            case .peripheralReady:
93
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // Change `2.0` to the desired number of seconds.
94
                    self.dataDumpRequest()
95
                }
96
            case .comunicating:
97
                break
98
            case .dataIsAvailable:
99
                break
100
            }
101
        }
102
    }
103

            
104
    static func operationalColor(for state: OperationalState) -> Color {
105
        switch state {
106
        case .notPresent:
107
            return .red
108
        case .peripheralNotConnected:
109
            return .blue
110
        case .peripheralConnectionPending:
111
            return .yellow
112
        case .peripheralConnected:
113
            return .yellow
114
        case .peripheralReady:
115
            return .orange
116
        case .comunicating:
117
            return .orange
118
        case .dataIsAvailable:
119
            return .green
120
        }
121
    }
122

            
123
    private var wdTimer: Timer?
124

            
125
    @Published var lastSeen = Date() {
126
        didSet {
127
            wdTimer?.invalidate()
128
            if operationalState == .peripheralNotConnected {
129
                wdTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false, block: {_ in
130
                    track("\(self.name) - Lost advertisments...")
131
                    self.operationalState = .notPresent
132
                })
133
            } else if operationalState == .notPresent {
134
               operationalState = .peripheralNotConnected
135
            }
136
        }
137
    }
138

            
139
    var uuid: UUID
140
    var model: Model
141
    var modelString: String
142

            
143
    var name: String {
144
        didSet {
145
            appData.meterNames[btSerial.macAddress.description] = name
146
        }
147
    }
148

            
149
    var color : Color {
150
        get {
Bogdan Timofte authored 2 weeks ago
151
            return model.color
Bogdan Timofte authored 2 weeks ago
152
        }
153
    }
154

            
Bogdan Timofte authored 2 weeks ago
155
    var capabilities: MeterCapabilities {
156
        model.capabilities
157
    }
158

            
Bogdan Timofte authored 2 weeks ago
159
    var availableDataGroupIDs: [UInt8] {
Bogdan Timofte authored 2 weeks ago
160
        capabilities.availableDataGroupIDs
Bogdan Timofte authored 2 weeks ago
161
    }
162

            
163
    var supportsDataGroupCommands: Bool {
Bogdan Timofte authored 2 weeks ago
164
        capabilities.supportsDataGroupCommands
Bogdan Timofte authored 2 weeks ago
165
    }
166

            
Bogdan Timofte authored 2 weeks ago
167
    var supportsRecordingView: Bool {
168
        capabilities.supportsRecordingView
169
    }
170

            
Bogdan Timofte authored 2 weeks ago
171
    var supportsUMSettings: Bool {
Bogdan Timofte authored 2 weeks ago
172
        capabilities.supportsScreenSettings
Bogdan Timofte authored 2 weeks ago
173
    }
174

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

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

            
183
    var showsDataGroupEnergy: Bool {
184
        capabilities.showsDataGroupEnergy
185
    }
186

            
187
    var highlightsActiveDataGroup: Bool {
188
        if model == .TC66C {
189
            return hasObservedActiveDataGroup
190
        }
191
        return capabilities.highlightsActiveDataGroup
192
    }
193

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

            
Bogdan Timofte authored 2 weeks ago
198
    var supportsManualTemperatureUnitSelection: Bool {
199
        model == .TC66C
200
    }
201

            
Bogdan Timofte authored 2 weeks ago
202
    var supportsChargerDetection: Bool {
Bogdan Timofte authored 2 weeks ago
203
        capabilities.supportsChargerDetection
204
    }
205

            
Bogdan Timofte authored 2 weeks ago
206
    var dataGroupsTitle: String {
207
        capabilities.dataGroupsTitle
208
    }
209

            
Bogdan Timofte authored 2 weeks ago
210
    var documentedWorkingVoltage: String {
211
        capabilities.documentedWorkingVoltage
212
    }
213

            
Bogdan Timofte authored 2 weeks ago
214
    var chargerTypeDescription: String {
215
        capabilities.chargerTypeDescription(for: chargerTypeIndex)
Bogdan Timofte authored 2 weeks ago
216
    }
217

            
Bogdan Timofte authored 2 weeks ago
218
    var temperatureUnitDescription: String {
219
        if supportsManualTemperatureUnitSelection {
220
            return tc66TemperatureUnitPreference.title
221
        }
222
        return supportsFahrenheit ? "Celsius / Fahrenheit" : "Celsius"
223
    }
224

            
225
    var primaryTemperatureDescription: String {
226
        let value = temperatureCelsius.format(decimalDigits: 0)
227
        if supportsManualTemperatureUnitSelection {
228
            return "\(value)\(tc66TemperatureUnitPreference.symbol)"
229
        }
230
        if let symbol = capabilities.primaryTemperatureUnitSymbol {
231
            return "\(value)\(symbol)"
232
        }
233
        return value
234
    }
235

            
236
    var secondaryTemperatureDescription: String? {
237
        guard supportsFahrenheit else { return nil }
238
        return "\(temperatureFahrenheit.format(decimalDigits: 0))℉"
239
    }
240

            
Bogdan Timofte authored 2 weeks ago
241
    var currentScreenDescription: String {
Bogdan Timofte authored 2 weeks ago
242
        guard reportsCurrentScreenIndex else {
243
            return "Page Controls"
244
        }
Bogdan Timofte authored 2 weeks ago
245
        if let label = capabilities.screenDescription(for: currentScreen) {
246
            return "Screen \(currentScreen): \(label)"
247
        }
248
        return "Screen \(currentScreen)"
249
    }
250

            
Bogdan Timofte authored 2 weeks ago
251
    var deviceModelSummary: String {
252
        let baseName = reportedModelName.isEmpty ? modelString : reportedModelName
253
        if modelNumber != 0 {
254
            return "\(baseName) (\(modelNumber))"
255
        }
256
        return baseName
257
    }
258

            
Bogdan Timofte authored 2 weeks ago
259
    var recordingDurationDescription: String {
260
        let totalSeconds = Int(recordingDuration)
261
        let hours = totalSeconds / 3600
262
        let minutes = (totalSeconds % 3600) / 60
263
        let seconds = totalSeconds % 60
264

            
265
        if hours > 0 {
266
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
267
        }
268
        return String(format: "%02d:%02d", minutes, seconds)
269
    }
270

            
Bogdan Timofte authored 2 weeks ago
271
    var chargeRecordDurationDescription: String {
272
        let totalSeconds = Int(chargeRecordDuration)
273
        let hours = totalSeconds / 3600
274
        let minutes = (totalSeconds % 3600) / 60
275
        let seconds = totalSeconds % 60
276

            
277
        if hours > 0 {
278
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
279
        }
280
        return String(format: "%02d:%02d", minutes, seconds)
281
    }
282

            
283
    var chargeRecordTimeRange: ClosedRange<Date>? {
284
        guard let start = chargeRecordStartTimestamp else { return nil }
285
        let end = chargeRecordEndTimestamp ?? chargeRecordLastTimestamp
286
        guard let end else { return nil }
287
        return start...end
288
    }
289

            
290
    var chargeRecordStatusText: String {
291
        switch chargeRecordState {
292
        case .waitingForStart:
293
            return "Waiting"
294
        case .active:
295
            return "Active"
296
        case .completed:
297
            return "Completed"
298
        }
299
    }
300

            
301
    var chargeRecordStatusColor: Color {
302
        switch chargeRecordState {
303
        case .waitingForStart:
304
            return .secondary
305
        case .active:
306
            return .red
307
        case .completed:
308
            return .green
309
        }
310
    }
311

            
Bogdan Timofte authored 2 weeks ago
312
    var dataGroupsHint: String? {
Bogdan Timofte authored 2 weeks ago
313
        if model == .TC66C {
314
            if hasObservedActiveDataGroup {
315
                return "The active memory is inferred from the totals that are currently increasing."
316
            }
317
            return "The device exposes two read-only memories. The active memory is inferred once one total starts increasing."
318
        }
319
        return capabilities.dataGroupsHint
320
    }
321

            
322
    func dataGroupLabel(for id: UInt8) -> String {
323
        supportsDataGroupCommands ? "\(id)" : "M\(Int(id) + 1)"
Bogdan Timofte authored 2 weeks ago
324
    }
325

            
326
    var recordingThresholdHint: String? {
327
        capabilities.recordingThresholdHint
328
    }
329

            
Bogdan Timofte authored 2 weeks ago
330
    @Published var btSerial: BluetoothSerial
331

            
332
    @Published var measurements = Measurements()
333

            
334
    private var commandQueue: [Data] = []
335
    private var dataDumpRequestTimestamp = Date()
336

            
337
    class DataGroupRecord {
338
        @Published var ah: Double
339
        @Published var wh: Double
340
        init(ah: Double, wh: Double) {
341
            self.ah = ah
342
            self.wh = wh
343
        }
344
    }
345
    @Published var selectedDataGroup: UInt8 = 0
346
    @Published var dataGroupRecords: [Int : DataGroupRecord] = [:]
Bogdan Timofte authored 2 weeks ago
347
    @Published var chargeRecordAH: Double = 0
348
    @Published var chargeRecordWH: Double = 0
349
    @Published var chargeRecordDuration: TimeInterval = 0
350
    @Published var chargeRecordStopThreshold: Double = 0.05
351
    @Published var tc66TemperatureUnitPreference: TemperatureUnitPreference = .celsius {
352
        didSet {
353
            guard supportsManualTemperatureUnitSelection else { return }
354
            guard oldValue != tc66TemperatureUnitPreference else { return }
355
            var settings = appData.tc66TemperatureUnits
356
            settings[btSerial.macAddress.description] = tc66TemperatureUnitPreference.rawValue
357
            appData.tc66TemperatureUnits = settings
358
        }
359
    }
Bogdan Timofte authored 2 weeks ago
360

            
361
    @Published var screenBrightness: Int = -1 {
362
        didSet {
363
            if oldValue != screenBrightness {
364
                screenBrightnessTimestamp = Date()
365
                if oldValue != -1 {
366
                    setSceeenBrightness(to: UInt8(screenBrightness))
367
                }
368
            }
369
        }
370
    }
371
    private var screenBrightnessTimestamp = Date()
372

            
373
    @Published var screenTimeout: Int = -1 {
374
        didSet {
375
            if oldValue != screenTimeout {
376
                screenTimeoutTimestamp = Date()
377
                if oldValue != -1 {
378
                    setScreenSaverTimeout(to: UInt8(screenTimeout))
379
                }
380
            }
381
        }
382
    }
383
    private var screenTimeoutTimestamp = Date()
384

            
385
    @Published var voltage: Double = 0
386
    @Published var current: Double = 0
387
    @Published var power: Double = 0
388
    @Published var temperatureCelsius: Double = 0
389
    @Published var temperatureFahrenheit: Double = 0
390
    @Published var usbPlusVoltage: Double = 0
391
    @Published var usbMinusVoltage: Double = 0
392
    @Published var recordedAH: Double = 0
393
    @Published var recordedWH: Double = 0
394
    @Published var recording: Bool = false
Bogdan Timofte authored 2 weeks ago
395
    @Published var recordingTreshold: Double = 0 {
Bogdan Timofte authored 2 weeks ago
396
        didSet {
Bogdan Timofte authored 2 weeks ago
397
            guard recordingTreshold != oldValue else { return }
398
            if isApplyingRecordingThresholdFromDevice {
399
                return
Bogdan Timofte authored 2 weeks ago
400
            }
Bogdan Timofte authored 2 weeks ago
401
            recordingThresholdTimestamp = Date()
402
            guard recordingThresholdLoadedFromDevice else { return }
403
            setrecordingTreshold(to: (recordingTreshold * 100).uInt8Value)
Bogdan Timofte authored 2 weeks ago
404
        }
Bogdan Timofte authored 2 weeks ago
405
    }
Bogdan Timofte authored 2 weeks ago
406
    @Published var currentScreen: UInt16 = 0
407
    @Published var recordingDuration: UInt32 = 0
408
    @Published var loadResistance: Double = 0
409
    @Published var modelNumber: UInt16 = 0
410
    @Published var chargerTypeIndex: UInt16 = 0
Bogdan Timofte authored 2 weeks ago
411
    @Published var reportedModelName: String = ""
412
    @Published var firmwareVersion: String = ""
413
    @Published var serialNumber: UInt32 = 0
414
    @Published var bootCount: UInt32 = 0
Bogdan Timofte authored 2 weeks ago
415
    private var enableAutoConnect: Bool = false
Bogdan Timofte authored 2 weeks ago
416
    private var recordingThresholdTimestamp = Date()
417
    private var recordingThresholdLoadedFromDevice = false
418
    private var isApplyingRecordingThresholdFromDevice = false
Bogdan Timofte authored 2 weeks ago
419
    @Published private(set) var chargeRecordState = ChargeRecordState.waitingForStart
420
    private var chargeRecordStartTimestamp: Date?
421
    private var chargeRecordEndTimestamp: Date?
422
    private var chargeRecordLastTimestamp: Date?
423
    private var chargeRecordLastCurrent: Double = 0
424
    private var chargeRecordLastPower: Double = 0
425
    private var hasObservedActiveDataGroup = false
426
    private var hasSeenTC66Snapshot = false
Bogdan Timofte authored 2 weeks ago
427

            
428
    init ( model: Model, with serialPort: BluetoothSerial ) {
429
        uuid = serialPort.peripheral.identifier
430
        //dataStore.meterUUIDS.append(serialPort.peripheral.identifier)
431
        modelString = serialPort.peripheral.name!
432
        self.model = model
433
        btSerial = serialPort
434
        name = appData.meterNames[serialPort.macAddress.description] ?? serialPort.macAddress.description
435
        super.init()
436
        btSerial.delegate = self
Bogdan Timofte authored 2 weeks ago
437
        reloadTemperatureUnitPreference()
Bogdan Timofte authored 2 weeks ago
438
        //name = dataStore.meterNames[macAddress.description] ?? peripheral.name!
439
        for index in stride(from: 0, through: 9, by: 1) {
440
            dataGroupRecords[index] = DataGroupRecord(ah:0, wh: 0)
441
        }
442
    }
Bogdan Timofte authored 2 weeks ago
443

            
444
    func reloadTemperatureUnitPreference() {
445
        guard supportsManualTemperatureUnitSelection else { return }
446
        let rawValue = appData.tc66TemperatureUnits[btSerial.macAddress.description] ?? TemperatureUnitPreference.celsius.rawValue
447
        let persistedPreference = TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
448
        if tc66TemperatureUnitPreference != persistedPreference {
449
            tc66TemperatureUnitPreference = persistedPreference
450
        }
451
    }
Bogdan Timofte authored 2 weeks ago
452

            
453
    func dataDumpRequest() {
454
        if commandQueue.isEmpty {
455
            switch model {
456
            case .UM25C:
Bogdan Timofte authored 2 weeks ago
457
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
Bogdan Timofte authored 2 weeks ago
458
            case .UM34C:
Bogdan Timofte authored 2 weeks ago
459
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
Bogdan Timofte authored 2 weeks ago
460
            case .TC66C:
Bogdan Timofte authored 2 weeks ago
461
                btSerial.write(TC66Protocol.snapshotRequest, expectedResponseLength: 192)
Bogdan Timofte authored 2 weeks ago
462
            }
463
            dataDumpRequestTimestamp = Date()
464
            // track("\(name) - Request sent!")
465
        } else {
466
            track("Request delayed for: \(commandQueue.first!.hexEncodedStringValue)")
467
            btSerial.write( commandQueue.first! )
468
            commandQueue.removeFirst()
469
            DispatchQueue.main.asyncAfter( deadline: .now() + 1 )  {
470
                self.dataDumpRequest()
471
            }
472
        }
473
    }
474

            
475
    /**
476
     received data parser
477
     - parameter buffer cotains response for data dump request
478
     - Decription metod for TC66C AES ECB response  found [here](https:github.com/krzyzanowskim/CryptoSwift/issues/693)
479
     */
480
    func parseData ( from buffer: Data) {
481
        //track("\(name)")
482
        switch model {
483
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
484
            do {
485
                apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
486
            } catch {
487
                track("\(name) - Error: \(error)")
488
            }
Bogdan Timofte authored 2 weeks ago
489
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
490
            do {
491
                apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
492
            } catch {
493
                track("\(name) - Error: \(error)")
494
            }
Bogdan Timofte authored 2 weeks ago
495
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
496
            do {
497
                apply(tc66Snapshot: try TC66Protocol.parseSnapshot(from: buffer))
498
            } catch {
499
                track("\(name) - Error: \(error)")
500
            }
Bogdan Timofte authored 2 weeks ago
501
        }
Bogdan Timofte authored 2 weeks ago
502
        updateChargeRecord(at: dataDumpRequestTimestamp)
Bogdan Timofte authored 2 weeks ago
503
        measurements.addValues(timestamp: dataDumpRequestTimestamp, power: power, voltage: voltage, current: current)
504
//        DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
505
//            //track("\(name) - Scheduled new request.")
506
//        }
507
        operationalState = .dataIsAvailable
508
        dataDumpRequest()
509
    }
510

            
Bogdan Timofte authored 2 weeks ago
511
    private func apply(umSnapshot snapshot: UMSnapshot) {
512
        modelNumber = snapshot.modelNumber
513
        voltage = snapshot.voltage
514
        current = snapshot.current
515
        power = snapshot.power
516
        temperatureCelsius = snapshot.temperatureCelsius
517
        temperatureFahrenheit = snapshot.temperatureFahrenheit
518
        selectedDataGroup = snapshot.selectedDataGroup
519
        for (index, record) in snapshot.dataGroupRecords {
520
            dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
Bogdan Timofte authored 2 weeks ago
521
        }
Bogdan Timofte authored 2 weeks ago
522
        usbPlusVoltage = snapshot.usbPlusVoltage
523
        usbMinusVoltage = snapshot.usbMinusVoltage
524
        chargerTypeIndex = snapshot.chargerTypeIndex
525
        recordedAH = snapshot.recordedAH
526
        recordedWH = snapshot.recordedWH
Bogdan Timofte authored 2 weeks ago
527

            
528
        if recordingThresholdTimestamp < dataDumpRequestTimestamp || !recordingThresholdLoadedFromDevice {
529
            recordingThresholdLoadedFromDevice = true
530
            if recordingTreshold != snapshot.recordingThreshold {
531
                isApplyingRecordingThresholdFromDevice = true
532
                recordingTreshold = snapshot.recordingThreshold
533
                isApplyingRecordingThresholdFromDevice = false
534
            }
535
        } else {
536
            track("\(name) - Skip updating recordingThreshold (changed after request).")
537
        }
Bogdan Timofte authored 2 weeks ago
538
        recordingDuration = snapshot.recordingDuration
539
        recording = snapshot.recording
540

            
Bogdan Timofte authored 2 weeks ago
541
        if screenTimeoutTimestamp < dataDumpRequestTimestamp {
Bogdan Timofte authored 2 weeks ago
542
            if screenTimeout != snapshot.screenTimeout {
543
                screenTimeout = snapshot.screenTimeout
Bogdan Timofte authored 2 weeks ago
544
            }
545
        } else {
546
            track("\(name) - Skip updating screenTimeout (changed after request).")
547
        }
548

            
549
        if screenBrightnessTimestamp < dataDumpRequestTimestamp {
Bogdan Timofte authored 2 weeks ago
550
            if screenBrightness != snapshot.screenBrightness {
551
                screenBrightness = snapshot.screenBrightness
Bogdan Timofte authored 2 weeks ago
552
            }
553
        } else {
554
            track("\(name) - Skip updating screenBrightness (changed after request).")
555
        }
556

            
Bogdan Timofte authored 2 weeks ago
557
        currentScreen = snapshot.currentScreen
558
        loadResistance = snapshot.loadResistance
Bogdan Timofte authored 2 weeks ago
559
    }
560

            
Bogdan Timofte authored 2 weeks ago
561
    private func apply(tc66Snapshot snapshot: TC66Snapshot) {
Bogdan Timofte authored 2 weeks ago
562
        if hasSeenTC66Snapshot {
563
            inferTC66ActiveDataGroup(from: snapshot)
564
        } else {
565
            hasSeenTC66Snapshot = true
566
        }
Bogdan Timofte authored 2 weeks ago
567
        reportedModelName = snapshot.modelName
568
        firmwareVersion = snapshot.firmwareVersion
569
        serialNumber = snapshot.serialNumber
570
        bootCount = snapshot.bootCount
Bogdan Timofte authored 2 weeks ago
571
        voltage = snapshot.voltage
572
        current = snapshot.current
573
        power = snapshot.power
574
        loadResistance = snapshot.loadResistance
575
        for (index, record) in snapshot.dataGroupRecords {
576
            dataGroupRecords[index] = DataGroupRecord(ah: record.ah, wh: record.wh)
Bogdan Timofte authored 2 weeks ago
577
        }
Bogdan Timofte authored 2 weeks ago
578
        temperatureCelsius = snapshot.temperatureCelsius
579
        usbPlusVoltage = snapshot.usbPlusVoltage
580
        usbMinusVoltage = snapshot.usbMinusVoltage
Bogdan Timofte authored 2 weeks ago
581
    }
Bogdan Timofte authored 2 weeks ago
582

            
583
    private func inferTC66ActiveDataGroup(from snapshot: TC66Snapshot) {
584
        let candidate = snapshot.dataGroupRecords.compactMap { entry -> (UInt8, Double)? in
585
            let index = entry.key
586
            let record = entry.value
587
            guard let previous = dataGroupRecords[index] else { return nil }
588
            let deltaAH = max(record.ah - previous.ah, 0)
589
            let deltaWH = max(record.wh - previous.wh, 0)
590
            let score = deltaAH + deltaWH
591
            guard score > 0 else { return nil }
592
            return (UInt8(index), score)
593
        }
594
        .max { lhs, rhs in lhs.1 < rhs.1 }
595

            
596
        if let candidate {
597
            selectedDataGroup = candidate.0
598
            hasObservedActiveDataGroup = true
599
        }
600
    }
601

            
602
    private func updateChargeRecord(at timestamp: Date) {
603
        switch chargeRecordState {
604
        case .waitingForStart:
605
            guard current > chargeRecordStopThreshold else { return }
606
            chargeRecordState = .active
607
            chargeRecordStartTimestamp = timestamp
608
            chargeRecordEndTimestamp = timestamp
609
            chargeRecordLastTimestamp = timestamp
610
            chargeRecordLastCurrent = current
611
            chargeRecordLastPower = power
612
        case .active:
613
            if let lastTimestamp = chargeRecordLastTimestamp {
614
                let deltaSeconds = max(timestamp.timeIntervalSince(lastTimestamp), 0)
615
                chargeRecordAH += chargeRecordLastCurrent * deltaSeconds / 3600
616
                chargeRecordWH += chargeRecordLastPower * deltaSeconds / 3600
617
                chargeRecordDuration += deltaSeconds
618
            }
619
            chargeRecordEndTimestamp = timestamp
620
            chargeRecordLastTimestamp = timestamp
621
            chargeRecordLastCurrent = current
622
            chargeRecordLastPower = power
623
            if current <= chargeRecordStopThreshold {
624
                chargeRecordState = .completed
625
            }
626
        case .completed:
627
            break
628
        }
629
    }
630

            
631
    func resetChargeRecord() {
632
        chargeRecordAH = 0
633
        chargeRecordWH = 0
634
        chargeRecordDuration = 0
635
        chargeRecordState = .waitingForStart
636
        chargeRecordStartTimestamp = nil
637
        chargeRecordEndTimestamp = nil
638
        chargeRecordLastTimestamp = nil
639
        chargeRecordLastCurrent = 0
640
        chargeRecordLastPower = 0
641
    }
642

            
643
    func resetChargeRecordGraph() {
644
        let cutoff = Date()
645
        resetChargeRecord()
646
        measurements.trim(before: cutoff)
647
    }
Bogdan Timofte authored 2 weeks ago
648

            
649
    func nextScreen() {
650
        switch model {
651
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
652
            commandQueue.append(UMProtocol.nextScreen)
Bogdan Timofte authored 2 weeks ago
653
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
654
            commandQueue.append(UMProtocol.nextScreen)
Bogdan Timofte authored 2 weeks ago
655
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
656
            commandQueue.append(TC66Protocol.nextPage)
Bogdan Timofte authored 2 weeks ago
657
        }
658
    }
659

            
660
    func rotateScreen() {
661
        switch model {
662
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
663
            commandQueue.append(UMProtocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
664
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
665
            commandQueue.append(UMProtocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
666
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
667
            commandQueue.append(TC66Protocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
668
        }
669
    }
670

            
671
    func previousScreen() {
672
        switch model {
673
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
674
            commandQueue.append(UMProtocol.previousScreen)
Bogdan Timofte authored 2 weeks ago
675
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
676
            commandQueue.append(UMProtocol.previousScreen)
Bogdan Timofte authored 2 weeks ago
677
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
678
            commandQueue.append(TC66Protocol.previousPage)
Bogdan Timofte authored 2 weeks ago
679
        }
680
    }
681

            
682
    func clear() {
Bogdan Timofte authored 2 weeks ago
683
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
684
        commandQueue.append(UMProtocol.clearCurrentGroup)
Bogdan Timofte authored 2 weeks ago
685
    }
686

            
687
    func clear(group id: UInt8) {
Bogdan Timofte authored 2 weeks ago
688
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
689
        commandQueue.append(UMProtocol.selectDataGroup(id))
Bogdan Timofte authored 2 weeks ago
690
        clear()
Bogdan Timofte authored 2 weeks ago
691
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
Bogdan Timofte authored 2 weeks ago
692
    }
693

            
694
    func selectDataGroup ( id: UInt8) {
Bogdan Timofte authored 2 weeks ago
695
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
696
        track("\(name) - \(id)")
697
        selectedDataGroup = id
Bogdan Timofte authored 2 weeks ago
698
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
Bogdan Timofte authored 2 weeks ago
699
    }
700

            
701
    private func setSceeenBrightness ( to value: UInt8) {
702
        track("\(name) - \(value)")
Bogdan Timofte authored 2 weeks ago
703
        guard supportsUMSettings else { return }
Bogdan Timofte authored 2 weeks ago
704
        commandQueue.append(UMProtocol.setScreenBrightness(value))
Bogdan Timofte authored 2 weeks ago
705
    }
706
    private func setScreenSaverTimeout ( to value: UInt8) {
707
        track("\(name) - \(value)")
Bogdan Timofte authored 2 weeks ago
708
        guard supportsUMSettings else { return }
Bogdan Timofte authored 2 weeks ago
709
        commandQueue.append(UMProtocol.setScreenSaverTimeout(value))
Bogdan Timofte authored 2 weeks ago
710
    }
711
    func setrecordingTreshold ( to value: UInt8) {
Bogdan Timofte authored 2 weeks ago
712
        guard supportsRecordingThreshold else { return }
Bogdan Timofte authored 2 weeks ago
713
        commandQueue.append(UMProtocol.setRecordingThreshold(value))
Bogdan Timofte authored 2 weeks ago
714
    }
715

            
716
    /**
717
     Connect to meter.
718
     1. It calls BluetoothSerial.connect
719
     */
720
    func connect() {
721
        enableAutoConnect = true
722
        btSerial.connect()
723
    }
724

            
725
    /**
726
     Disconnect from meter.
727
        It calls BluetoothSerial.disconnect
728
     */
729
    func disconnect() {
730
        enableAutoConnect = false
731
        btSerial.disconnect()
732
    }
733
}
734

            
735
extension Meter : SerialPortDelegate {
736

            
737
    func opertionalStateChanged(to serialPortOperationalState: BluetoothSerial.OperationalState) {
738
        lastSeen = Date()
739
        //track("\(name) - \(serialPortOperationalState)")
740
        switch serialPortOperationalState {
741
        case .peripheralNotConnected:
742
            operationalState = .peripheralNotConnected
743
        case .peripheralConnectionPending:
744
            operationalState = .peripheralConnectionPending
745
        case .peripheralConnected:
746
            operationalState = .peripheralConnected
747
        case .peripheralReady:
748
            operationalState = .peripheralReady
749
        }
750
    }
751

            
752
    func didReceiveData(_ data: Data) {
753
        lastSeen = Date()
754
        operationalState = .comunicating
755
        parseData(from: data)
756
    }
757
}