Newer Older
1005 lines | 35.478kb
Bogdan Timofte authored 2 weeks ago
1
//
2
//  File.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 03/03/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8
//MARK: Store and documentation: https://www.aliexpress.com/item/32968303350.html
9
//MARK: Protocol: https://sigrok.org/wiki/RDTech_UM_series
10
//MARK: Pithon Code: https://github.com/rfinnie/rdserialtool
11
//MARK: HM-10 Code: https://github.com/hoiberg/HM10-BluetoothSerial-iOS
12
//MARK: Package dependency https://github.com/krzyzanowskim/CryptoSwift
13

            
14
import CoreBluetooth
15
import SwiftUI
16

            
17
/**
18
 Supprted USB Meters
19
 # UM25C
20
 # TC66
21
 * Reverse Engineering
22
 [UM Series](https://sigrok.org/wiki/RDTech_UM_series)
23
 [TC66C](https://sigrok.org/wiki/RDTech_TC66C)
24
 */
Bogdan Timofte authored 2 weeks ago
25
enum Model: CaseIterable {
Bogdan Timofte authored 2 weeks ago
26
    case UM25C
27
    case UM34C
28
    case TC66C
29
}
30

            
Bogdan Timofte authored 2 weeks ago
31
enum TemperatureUnitPreference: String, CaseIterable, Identifiable {
32
    case celsius
33
    case fahrenheit
34

            
35
    var id: String { rawValue }
36

            
37
    var title: String {
38
        switch self {
39
        case .celsius:
40
            return "Celsius"
41
        case .fahrenheit:
42
            return "Fahrenheit"
43
        }
44
    }
45

            
46
    var symbol: String {
47
        switch self {
48
        case .celsius:
49
            return "℃"
50
        case .fahrenheit:
51
            return "℉"
52
        }
53
    }
54
}
55

            
Bogdan Timofte authored 2 weeks ago
56
private extension TemperatureUnitPreference {
57
    var localeTitle: String {
58
        switch self {
59
        case .celsius:
60
            return "System (Celsius)"
61
        case .fahrenheit:
62
            return "System (Fahrenheit)"
63
        }
64
    }
65
}
66

            
Bogdan Timofte authored 2 weeks ago
67
enum ChargeRecordState {
68
    case waitingForStart
69
    case active
70
    case completed
71
}
72

            
Bogdan Timofte authored 2 weeks ago
73
class Meter : NSObject, ObservableObject, Identifiable {
74

            
Bogdan Timofte authored 2 weeks ago
75
    private static func shouldLogOperationalStateTransition(from oldValue: OperationalState, to newValue: OperationalState) -> Bool {
76
        switch (oldValue, newValue) {
77
        case (.comunicating, .dataIsAvailable), (.dataIsAvailable, .comunicating):
78
            return false
79
        default:
80
            return true
81
        }
82
    }
83

            
Bogdan Timofte authored 2 weeks ago
84
    enum OperationalState: Int, Comparable {
85
        case notPresent
86
        case peripheralNotConnected
87
        case peripheralConnectionPending
88
        case peripheralConnected
89
        case peripheralReady
90
        case comunicating
91
        case dataIsAvailable
92

            
93
        static func < (lhs: OperationalState, rhs: OperationalState) -> Bool {
94
            return lhs.rawValue < rhs.rawValue
95
        }
96
    }
97

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

            
134
    static func operationalColor(for state: OperationalState) -> Color {
135
        switch state {
136
        case .notPresent:
137
            return .red
138
        case .peripheralNotConnected:
139
            return .blue
140
        case .peripheralConnectionPending:
141
            return .yellow
142
        case .peripheralConnected:
143
            return .yellow
144
        case .peripheralReady:
145
            return .orange
146
        case .comunicating:
147
            return .orange
148
        case .dataIsAvailable:
149
            return .green
150
        }
151
    }
152

            
153
    private var wdTimer: Timer?
154

            
Bogdan Timofte authored 2 weeks ago
155
    var lastSeen = Date() {
Bogdan Timofte authored 2 weeks ago
156
        didSet {
157
            wdTimer?.invalidate()
158
            if operationalState == .peripheralNotConnected {
159
                wdTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false, block: {_ in
160
                    track("\(self.name) - Lost advertisments...")
161
                    self.operationalState = .notPresent
162
                })
163
            } else if operationalState == .notPresent {
164
               operationalState = .peripheralNotConnected
165
            }
166
        }
167
    }
168

            
169
    var uuid: UUID
170
    var model: Model
171
    var modelString: String
172

            
Bogdan Timofte authored a week ago
173
    private var isSyncingNameFromStore = false
174

            
175
    @Published var name: String {
Bogdan Timofte authored 2 weeks ago
176
        didSet {
Bogdan Timofte authored a week ago
177
            guard !isSyncingNameFromStore else { return }
178
            guard oldValue != name else { return }
179
            appData.setMeterName(name, for: btSerial.macAddress.description)
Bogdan Timofte authored 2 weeks ago
180
        }
181
    }
182

            
183
    var color : Color {
184
        get {
Bogdan Timofte authored 2 weeks ago
185
            return model.color
Bogdan Timofte authored 2 weeks ago
186
        }
187
    }
188

            
Bogdan Timofte authored 2 weeks ago
189
    var capabilities: MeterCapabilities {
190
        model.capabilities
191
    }
192

            
Bogdan Timofte authored 2 weeks ago
193
    var availableDataGroupIDs: [UInt8] {
Bogdan Timofte authored 2 weeks ago
194
        capabilities.availableDataGroupIDs
Bogdan Timofte authored 2 weeks ago
195
    }
196

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

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

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

            
209
    var supportsRecordingThreshold: Bool {
Bogdan Timofte authored 2 weeks ago
210
        capabilities.supportsRecordingThreshold
Bogdan Timofte authored 2 weeks ago
211
    }
212

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

            
217
    var showsDataGroupEnergy: Bool {
218
        capabilities.showsDataGroupEnergy
219
    }
220

            
221
    var highlightsActiveDataGroup: Bool {
222
        if model == .TC66C {
223
            return hasObservedActiveDataGroup
224
        }
225
        return capabilities.highlightsActiveDataGroup
226
    }
227

            
Bogdan Timofte authored 2 weeks ago
228
    var supportsFahrenheit: Bool {
Bogdan Timofte authored 2 weeks ago
229
        capabilities.supportsFahrenheit
Bogdan Timofte authored 2 weeks ago
230
    }
231

            
Bogdan Timofte authored 2 weeks ago
232
    var supportsManualTemperatureUnitSelection: Bool {
233
        model == .TC66C
234
    }
235

            
Bogdan Timofte authored 2 weeks ago
236
    var supportsChargerDetection: Bool {
Bogdan Timofte authored 2 weeks ago
237
        capabilities.supportsChargerDetection
238
    }
239

            
Bogdan Timofte authored 2 weeks ago
240
    var dataGroupsTitle: String {
241
        capabilities.dataGroupsTitle
242
    }
243

            
Bogdan Timofte authored 2 weeks ago
244
    var documentedWorkingVoltage: String {
245
        capabilities.documentedWorkingVoltage
246
    }
247

            
Bogdan Timofte authored 2 weeks ago
248
    var chargerTypeDescription: String {
249
        capabilities.chargerTypeDescription(for: chargerTypeIndex)
Bogdan Timofte authored 2 weeks ago
250
    }
251

            
Bogdan Timofte authored 2 weeks ago
252
    var temperatureUnitDescription: String {
253
        if supportsManualTemperatureUnitSelection {
Bogdan Timofte authored 2 weeks ago
254
            return "Device-defined"
Bogdan Timofte authored 2 weeks ago
255
        }
Bogdan Timofte authored 2 weeks ago
256
        return systemTemperatureUnitPreference.localeTitle
Bogdan Timofte authored 2 weeks ago
257
    }
258

            
259
    var primaryTemperatureDescription: String {
Bogdan Timofte authored 2 weeks ago
260
        let value = displayedTemperatureValue.format(decimalDigits: 0)
Bogdan Timofte authored 2 weeks ago
261
        if supportsManualTemperatureUnitSelection {
Bogdan Timofte authored 2 weeks ago
262
            return "\(value)°"
Bogdan Timofte authored 2 weeks ago
263
        }
Bogdan Timofte authored 2 weeks ago
264
        return "\(value)\(systemTemperatureUnitPreference.symbol)"
Bogdan Timofte authored 2 weeks ago
265
    }
266

            
267
    var secondaryTemperatureDescription: String? {
Bogdan Timofte authored 2 weeks ago
268
        nil
269
    }
270

            
271
    var displayedTemperatureValue: Double {
272
        if supportsManualTemperatureUnitSelection {
273
            return temperatureCelsius
274
        }
275
        switch systemTemperatureUnitPreference {
276
        case .celsius:
277
            return displayedTemperatureCelsius
278
        case .fahrenheit:
279
            return displayedTemperatureFahrenheit
280
        }
281
    }
282

            
283
    private var displayedTemperatureCelsius: Double {
284
        if supportsManualTemperatureUnitSelection {
285
            switch tc66TemperatureUnitPreference {
286
            case .celsius:
287
                return temperatureCelsius
288
            case .fahrenheit:
289
                return (temperatureCelsius - 32) * 5 / 9
290
            }
291
        }
292
        return temperatureCelsius
293
    }
294

            
295
    private var displayedTemperatureFahrenheit: Double {
296
        if supportsManualTemperatureUnitSelection {
297
            switch tc66TemperatureUnitPreference {
298
            case .celsius:
299
                return (temperatureCelsius * 9 / 5) + 32
300
            case .fahrenheit:
301
                return temperatureCelsius
302
            }
303
        }
304
        if supportsFahrenheit, temperatureFahrenheit.isFinite {
305
            return temperatureFahrenheit
306
        }
307
        return (temperatureCelsius * 9 / 5) + 32
308
    }
309

            
310
    private var systemTemperatureUnitPreference: TemperatureUnitPreference {
311
        let locale = Locale.autoupdatingCurrent
312
        if #available(iOS 16.0, *) {
313
            switch locale.measurementSystem {
314
            case .us:
315
                return .fahrenheit
316
            default:
317
                return .celsius
318
            }
319
        }
320

            
321
        let regionCode = locale.regionCode ?? ""
322
        let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
323
        return fahrenheitRegions.contains(regionCode) ? .fahrenheit : .celsius
Bogdan Timofte authored 2 weeks ago
324
    }
325

            
Bogdan Timofte authored 2 weeks ago
326
    var currentScreenDescription: String {
Bogdan Timofte authored 2 weeks ago
327
        guard reportsCurrentScreenIndex else {
328
            return "Page Controls"
329
        }
Bogdan Timofte authored 2 weeks ago
330
        if let label = capabilities.screenDescription(for: currentScreen) {
331
            return "Screen \(currentScreen): \(label)"
332
        }
333
        return "Screen \(currentScreen)"
334
    }
335

            
Bogdan Timofte authored 2 weeks ago
336
    var deviceModelName: String {
Bogdan Timofte authored 2 weeks ago
337
        if !reportedModelName.isEmpty {
338
            return reportedModelName
339
        }
340
        return model.canonicalName
Bogdan Timofte authored 2 weeks ago
341
    }
342

            
Bogdan Timofte authored 2 weeks ago
343
    var deviceModelSummary: String {
Bogdan Timofte authored 2 weeks ago
344
        let baseName = deviceModelName
Bogdan Timofte authored 2 weeks ago
345
        if modelNumber != 0 {
346
            return "\(baseName) (\(modelNumber))"
347
        }
348
        return baseName
349
    }
350

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

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

            
Bogdan Timofte authored 2 weeks ago
363
    var chargeRecordDurationDescription: String {
364
        let totalSeconds = Int(chargeRecordDuration)
365
        let hours = totalSeconds / 3600
366
        let minutes = (totalSeconds % 3600) / 60
367
        let seconds = totalSeconds % 60
368

            
369
        if hours > 0 {
370
            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
371
        }
372
        return String(format: "%02d:%02d", minutes, seconds)
373
    }
374

            
375
    var chargeRecordTimeRange: ClosedRange<Date>? {
376
        guard let start = chargeRecordStartTimestamp else { return nil }
377
        let end = chargeRecordEndTimestamp ?? chargeRecordLastTimestamp
378
        guard let end else { return nil }
379
        return start...end
380
    }
381

            
382
    var chargeRecordStatusText: String {
383
        switch chargeRecordState {
384
        case .waitingForStart:
385
            return "Waiting"
386
        case .active:
387
            return "Active"
388
        case .completed:
389
            return "Completed"
390
        }
391
    }
392

            
393
    var chargeRecordStatusColor: Color {
394
        switch chargeRecordState {
395
        case .waitingForStart:
396
            return .secondary
397
        case .active:
398
            return .red
399
        case .completed:
400
            return .green
401
        }
402
    }
403

            
Bogdan Timofte authored 2 weeks ago
404
    var dataGroupsHint: String? {
Bogdan Timofte authored 2 weeks ago
405
        if model == .TC66C {
406
            if hasObservedActiveDataGroup {
407
                return "The active memory is inferred from the totals that are currently increasing."
408
            }
409
            return "The device exposes two read-only memories. The active memory is inferred once one total starts increasing."
410
        }
411
        return capabilities.dataGroupsHint
412
    }
413

            
414
    func dataGroupLabel(for id: UInt8) -> String {
415
        supportsDataGroupCommands ? "\(id)" : "M\(Int(id) + 1)"
Bogdan Timofte authored 2 weeks ago
416
    }
417

            
418
    var recordingThresholdHint: String? {
419
        capabilities.recordingThresholdHint
420
    }
421

            
Bogdan Timofte authored 2 weeks ago
422
    var btSerial: BluetoothSerial
Bogdan Timofte authored 2 weeks ago
423

            
Bogdan Timofte authored 2 weeks ago
424
    var measurements = Measurements()
Bogdan Timofte authored 2 weeks ago
425

            
426
    private var commandQueue: [Data] = []
427
    private var dataDumpRequestTimestamp = Date()
Bogdan Timofte authored 2 weeks ago
428
    private var pendingDataDumpWorkItem: DispatchWorkItem?
Bogdan Timofte authored 2 weeks ago
429

            
430
    class DataGroupRecord {
Bogdan Timofte authored 2 weeks ago
431
        var ah: Double
432
        var wh: Double
Bogdan Timofte authored 2 weeks ago
433
        init(ah: Double, wh: Double) {
434
            self.ah = ah
435
            self.wh = wh
436
        }
437
    }
Bogdan Timofte authored 2 weeks ago
438
    private(set) var selectedDataGroup: UInt8 = 0
439
    private(set) var dataGroupRecords: [Int : DataGroupRecord] = [:]
440
    private(set) var chargeRecordAH: Double = 0
441
    private(set) var chargeRecordWH: Double = 0
442
    private(set) var chargeRecordDuration: TimeInterval = 0
Bogdan Timofte authored 2 weeks ago
443
    @Published var chargeRecordStopThreshold: Double = 0.05
444
    @Published var tc66TemperatureUnitPreference: TemperatureUnitPreference = .celsius {
445
        didSet {
446
            guard supportsManualTemperatureUnitSelection else { return }
447
            guard oldValue != tc66TemperatureUnitPreference else { return }
Bogdan Timofte authored a week ago
448
            appData.setTemperatureUnitPreference(tc66TemperatureUnitPreference, for: btSerial.macAddress.description)
Bogdan Timofte authored 2 weeks ago
449
        }
450
    }
Bogdan Timofte authored 2 weeks ago
451

            
452
    @Published var screenBrightness: Int = -1 {
453
        didSet {
454
            if oldValue != screenBrightness {
455
                screenBrightnessTimestamp = Date()
456
                if oldValue != -1 {
457
                    setSceeenBrightness(to: UInt8(screenBrightness))
458
                }
459
            }
460
        }
461
    }
462
    private var screenBrightnessTimestamp = Date()
463

            
464
    @Published var screenTimeout: Int = -1 {
465
        didSet {
466
            if oldValue != screenTimeout {
467
                screenTimeoutTimestamp = Date()
468
                if oldValue != -1 {
469
                    setScreenSaverTimeout(to: UInt8(screenTimeout))
470
                }
471
            }
472
        }
473
    }
474
    private var screenTimeoutTimestamp = Date()
475

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

            
Bogdan Timofte authored 2 weeks ago
525
    @discardableResult
526
    private func setIfChanged<T: Equatable>(_ keyPath: ReferenceWritableKeyPath<Meter, T>, to value: T) -> Bool {
527
        guard self[keyPath: keyPath] != value else { return false }
528
        self[keyPath: keyPath] = value
529
        liveDataChanged = true
530
        return true
531
    }
532

            
533
    private func updateDataGroupRecord(index: Int, ah: Double, wh: Double) {
534
        if let existing = dataGroupRecords[index] {
535
            if existing.ah != ah { existing.ah = ah; liveDataChanged = true }
536
            if existing.wh != wh { existing.wh = wh; liveDataChanged = true }
537
        } else {
538
            dataGroupRecords[index] = DataGroupRecord(ah: ah, wh: wh)
539
            liveDataChanged = true
540
        }
541
    }
542

            
Bogdan Timofte authored 2 weeks ago
543
    init ( model: Model, with serialPort: BluetoothSerial ) {
544
        uuid = serialPort.peripheral.identifier
545
        //dataStore.meterUUIDS.append(serialPort.peripheral.identifier)
546
        modelString = serialPort.peripheral.name!
547
        self.model = model
548
        btSerial = serialPort
Bogdan Timofte authored a week ago
549
        name = appData.meterName(for: serialPort.macAddress.description) ?? serialPort.macAddress.description
Bogdan Timofte authored 2 weeks ago
550
        super.init()
551
        btSerial.delegate = self
Bogdan Timofte authored 2 weeks ago
552
        reloadTemperatureUnitPreference()
Bogdan Timofte authored 2 weeks ago
553
        //name = dataStore.meterNames[macAddress.description] ?? peripheral.name!
554
        for index in stride(from: 0, through: 9, by: 1) {
555
            dataGroupRecords[index] = DataGroupRecord(ah:0, wh: 0)
556
        }
557
    }
Bogdan Timofte authored 2 weeks ago
558

            
559
    func reloadTemperatureUnitPreference() {
560
        guard supportsManualTemperatureUnitSelection else { return }
Bogdan Timofte authored a week ago
561
        let persistedPreference = appData.temperatureUnitPreference(for: btSerial.macAddress.description)
Bogdan Timofte authored 2 weeks ago
562
        if tc66TemperatureUnitPreference != persistedPreference {
563
            tc66TemperatureUnitPreference = persistedPreference
564
        }
565
    }
Bogdan Timofte authored 2 weeks ago
566

            
Bogdan Timofte authored a week ago
567
    func updateNameFromStore(_ newName: String) {
568
        guard newName != name else { return }
569
        isSyncingNameFromStore = true
570
        name = newName
571
        isSyncingNameFromStore = false
572
    }
573

            
Bogdan Timofte authored 2 weeks ago
574
    private func cancelPendingDataDumpRequest(reason: String) {
575
        guard let pendingDataDumpWorkItem else { return }
576
        track("\(name) - Cancel scheduled data request (\(reason))")
577
        pendingDataDumpWorkItem.cancel()
578
        self.pendingDataDumpWorkItem = nil
579
    }
580

            
581
    private func scheduleDataDumpRequest(after delay: TimeInterval, reason: String) {
582
        cancelPendingDataDumpRequest(reason: "reschedule")
583

            
584
        let workItem = DispatchWorkItem { [weak self] in
585
            guard let self else { return }
586
            self.pendingDataDumpWorkItem = nil
587
            self.dataDumpRequest()
588
        }
589
        pendingDataDumpWorkItem = workItem
590
        track("\(name) - Schedule data request in \(String(format: "%.2f", delay))s (\(reason))")
591
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
592
    }
Bogdan Timofte authored 2 weeks ago
593

            
594
    private func noteInitiatedVolatileMemoryResetIfNeeded(for groupID: UInt8) {
595
        guard groupID == 0 else { return }
596
        pendingVolatileMemoryResetIgnoreCount += 1
597
        pendingVolatileMemoryResetDeadline = Date().addingTimeInterval(initiatedVolatileMemoryResetGraceWindow)
598
        track("\(name) - Will ignore the next volatile memory drop caused by an app reset request.")
599
    }
600

            
601
    private func pendingInitiatedVolatileMemoryResetIsActive(at timestamp: Date) -> Bool {
602
        guard let pendingVolatileMemoryResetDeadline else { return false }
603
        guard pendingVolatileMemoryResetIgnoreCount > 0 else {
604
            self.pendingVolatileMemoryResetDeadline = nil
605
            return false
606
        }
607
        guard timestamp <= pendingVolatileMemoryResetDeadline else {
608
            track("\(name) - Expiring stale volatile memory reset ignore state.")
609
            pendingVolatileMemoryResetIgnoreCount = 0
610
            self.pendingVolatileMemoryResetDeadline = nil
611
            return false
612
        }
613
        return true
614
    }
615

            
616
    private func shouldIgnoreVolatileMemoryDrop(at timestamp: Date) -> Bool {
617
        guard pendingInitiatedVolatileMemoryResetIsActive(at: timestamp) else { return false }
618
        pendingVolatileMemoryResetIgnoreCount -= 1
619
        if pendingVolatileMemoryResetIgnoreCount == 0 {
620
            pendingVolatileMemoryResetDeadline = nil
621
        }
622
        track("\(name) - Ignoring volatile memory drop after an app-initiated reset.")
623
        return true
624
    }
625

            
626
    private func didUMVolatileMemoryDecrease(in snapshot: UMSnapshot) -> Bool {
627
        guard hasSeenUMSnapshot else { return false }
628
        guard let previousRecord = dataGroupRecords[0], let nextRecord = snapshot.dataGroupRecords[0] else {
629
            return false
630
        }
631

            
632
        return nextRecord.ah < (previousRecord.ah - volatileMemoryDecreaseEpsilon)
633
            || nextRecord.wh < (previousRecord.wh - volatileMemoryDecreaseEpsilon)
634
    }
635

            
636
    private func didUMDeviceReboot(with snapshot: UMSnapshot, at timestamp: Date) -> Bool {
637
        defer { hasSeenUMSnapshot = true }
638

            
639
        guard didUMVolatileMemoryDecrease(in: snapshot) else { return false }
640
        guard !shouldIgnoreVolatileMemoryDrop(at: timestamp) else { return false }
641

            
642
        track("\(name) - Inferred UM reboot because volatile memory dropped.")
643
        return true
644
    }
645

            
646
    private func didTC66DeviceReboot(with snapshot: TC66Snapshot) -> Bool {
647
        guard hasSeenTC66Snapshot else { return false }
648
        guard snapshot.bootCount != bootCount else { return false }
649

            
650
        track("\(name) - Inferred TC66 reboot because bootCount changed from \(bootCount) to \(snapshot.bootCount).")
651
        return true
652
    }
653

            
654
    private func updateChargerTypeLatch(observedIndex: UInt16, didDetectDeviceReset: Bool) {
655
        if didDetectDeviceReset, chargerTypeIndex != 0 {
Bogdan Timofte authored 2 weeks ago
656
            setIfChanged(\.chargerTypeIndex, to: 0)
Bogdan Timofte authored 2 weeks ago
657
        }
658

            
659
        guard supportsChargerDetection else { return }
660

            
661
        if chargerTypeIndex == 0 {
Bogdan Timofte authored 2 weeks ago
662
            setIfChanged(\.chargerTypeIndex, to: observedIndex)
Bogdan Timofte authored 2 weeks ago
663
            return
664
        }
665

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

            
670
    func dataDumpRequest() {
Bogdan Timofte authored 2 weeks ago
671
        guard operationalState >= .peripheralReady else {
672
            track("\(name) - Skip data request while state is \(operationalState)")
673
            return
674
        }
Bogdan Timofte authored 2 weeks ago
675
        if commandQueue.isEmpty {
676
            switch model {
677
            case .UM25C:
Bogdan Timofte authored 2 weeks ago
678
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
Bogdan Timofte authored 2 weeks ago
679
            case .UM34C:
Bogdan Timofte authored 2 weeks ago
680
                btSerial.write(UMProtocol.snapshotRequest, expectedResponseLength: 130)
Bogdan Timofte authored 2 weeks ago
681
            case .TC66C:
Bogdan Timofte authored 2 weeks ago
682
                btSerial.write(TC66Protocol.snapshotRequest, expectedResponseLength: 192)
Bogdan Timofte authored 2 weeks ago
683
            }
684
            dataDumpRequestTimestamp = Date()
685
            // track("\(name) - Request sent!")
686
        } else {
687
            track("Request delayed for: \(commandQueue.first!.hexEncodedStringValue)")
688
            btSerial.write( commandQueue.first! )
689
            commandQueue.removeFirst()
Bogdan Timofte authored 2 weeks ago
690
            scheduleDataDumpRequest(after: 1, reason: "queued command")
Bogdan Timofte authored 2 weeks ago
691
        }
692
    }
693

            
694
    /**
695
     received data parser
696
     - parameter buffer cotains response for data dump request
697
     - Decription metod for TC66C AES ECB response  found [here](https:github.com/krzyzanowskim/CryptoSwift/issues/693)
698
     */
699
    func parseData ( from buffer: Data) {
700
        //track("\(name)")
Bogdan Timofte authored 2 weeks ago
701
        liveDataChanged = false
Bogdan Timofte authored 2 weeks ago
702
        switch model {
703
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
704
            do {
705
                apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
706
            } catch {
707
                track("\(name) - Error: \(error)")
708
            }
Bogdan Timofte authored 2 weeks ago
709
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
710
            do {
711
                apply(umSnapshot: try UMProtocol.parseSnapshot(from: buffer, model: model))
712
            } catch {
713
                track("\(name) - Error: \(error)")
714
            }
Bogdan Timofte authored 2 weeks ago
715
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
716
            do {
717
                apply(tc66Snapshot: try TC66Protocol.parseSnapshot(from: buffer))
718
            } catch {
719
                track("\(name) - Error: \(error)")
720
            }
Bogdan Timofte authored 2 weeks ago
721
        }
Bogdan Timofte authored 2 weeks ago
722
        updateChargeRecord(at: dataDumpRequestTimestamp)
Bogdan Timofte authored 2 weeks ago
723
        measurements.addValues(timestamp: dataDumpRequestTimestamp, power: power, voltage: voltage, current: current)
724
//        DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
725
//            //track("\(name) - Scheduled new request.")
726
//        }
Bogdan Timofte authored 2 weeks ago
727
        if operationalState != .dataIsAvailable {
728
            operationalState = .dataIsAvailable
729
        } else if liveDataChanged {
730
            objectWillChange.send()
731
        }
Bogdan Timofte authored 2 weeks ago
732
        dataDumpRequest()
733
    }
734

            
Bogdan Timofte authored 2 weeks ago
735
    private func apply(umSnapshot snapshot: UMSnapshot) {
Bogdan Timofte authored 2 weeks ago
736
        let didDetectDeviceReset = didUMDeviceReboot(with: snapshot, at: dataDumpRequestTimestamp)
Bogdan Timofte authored 2 weeks ago
737
        setIfChanged(\.modelNumber, to: snapshot.modelNumber)
738
        setIfChanged(\.voltage, to: snapshot.voltage)
739
        setIfChanged(\.current, to: snapshot.current)
740
        setIfChanged(\.power, to: snapshot.power)
741
        setIfChanged(\.temperatureCelsius, to: snapshot.temperatureCelsius)
742
        setIfChanged(\.temperatureFahrenheit, to: snapshot.temperatureFahrenheit)
743
        setIfChanged(\.selectedDataGroup, to: snapshot.selectedDataGroup)
Bogdan Timofte authored 2 weeks ago
744
        for (index, record) in snapshot.dataGroupRecords {
Bogdan Timofte authored 2 weeks ago
745
            updateDataGroupRecord(index: index, ah: record.ah, wh: record.wh)
Bogdan Timofte authored 2 weeks ago
746
        }
Bogdan Timofte authored 2 weeks ago
747
        setIfChanged(\.usbPlusVoltage, to: snapshot.usbPlusVoltage)
748
        setIfChanged(\.usbMinusVoltage, to: snapshot.usbMinusVoltage)
Bogdan Timofte authored 2 weeks ago
749
        updateChargerTypeLatch(observedIndex: snapshot.chargerTypeIndex, didDetectDeviceReset: didDetectDeviceReset)
Bogdan Timofte authored 2 weeks ago
750
        setIfChanged(\.recordedAH, to: snapshot.recordedAH)
751
        setIfChanged(\.recordedWH, to: snapshot.recordedWH)
Bogdan Timofte authored 2 weeks ago
752

            
753
        if recordingThresholdTimestamp < dataDumpRequestTimestamp || !recordingThresholdLoadedFromDevice {
754
            recordingThresholdLoadedFromDevice = true
755
            if recordingTreshold != snapshot.recordingThreshold {
756
                isApplyingRecordingThresholdFromDevice = true
757
                recordingTreshold = snapshot.recordingThreshold
758
                isApplyingRecordingThresholdFromDevice = false
759
            }
760
        } else {
761
            track("\(name) - Skip updating recordingThreshold (changed after request).")
762
        }
Bogdan Timofte authored 2 weeks ago
763
        setIfChanged(\.recordingDuration, to: snapshot.recordingDuration)
764
        setIfChanged(\.recording, to: snapshot.recording)
Bogdan Timofte authored 2 weeks ago
765

            
Bogdan Timofte authored 2 weeks ago
766
        if screenTimeoutTimestamp < dataDumpRequestTimestamp {
Bogdan Timofte authored 2 weeks ago
767
            if screenTimeout != snapshot.screenTimeout {
768
                screenTimeout = snapshot.screenTimeout
Bogdan Timofte authored 2 weeks ago
769
            }
770
        } else {
771
            track("\(name) - Skip updating screenTimeout (changed after request).")
772
        }
773

            
774
        if screenBrightnessTimestamp < dataDumpRequestTimestamp {
Bogdan Timofte authored 2 weeks ago
775
            if screenBrightness != snapshot.screenBrightness {
776
                screenBrightness = snapshot.screenBrightness
Bogdan Timofte authored 2 weeks ago
777
            }
778
        } else {
779
            track("\(name) - Skip updating screenBrightness (changed after request).")
780
        }
781

            
Bogdan Timofte authored 2 weeks ago
782
        setIfChanged(\.currentScreen, to: snapshot.currentScreen)
783
        setIfChanged(\.loadResistance, to: snapshot.loadResistance)
Bogdan Timofte authored 2 weeks ago
784
    }
785

            
Bogdan Timofte authored 2 weeks ago
786
    private func apply(tc66Snapshot snapshot: TC66Snapshot) {
Bogdan Timofte authored 2 weeks ago
787
        let didDetectDeviceReset = didTC66DeviceReboot(with: snapshot)
Bogdan Timofte authored 2 weeks ago
788
        if hasSeenTC66Snapshot {
789
            inferTC66ActiveDataGroup(from: snapshot)
790
        } else {
791
            hasSeenTC66Snapshot = true
792
        }
Bogdan Timofte authored 2 weeks ago
793
        setIfChanged(\.reportedModelName, to: snapshot.modelName)
794
        setIfChanged(\.firmwareVersion, to: snapshot.firmwareVersion)
795
        setIfChanged(\.serialNumber, to: snapshot.serialNumber)
796
        setIfChanged(\.bootCount, to: snapshot.bootCount)
Bogdan Timofte authored 2 weeks ago
797
        updateChargerTypeLatch(observedIndex: 0, didDetectDeviceReset: didDetectDeviceReset)
Bogdan Timofte authored 2 weeks ago
798
        setIfChanged(\.voltage, to: snapshot.voltage)
799
        setIfChanged(\.current, to: snapshot.current)
800
        setIfChanged(\.power, to: snapshot.power)
801
        setIfChanged(\.loadResistance, to: snapshot.loadResistance)
Bogdan Timofte authored 2 weeks ago
802
        for (index, record) in snapshot.dataGroupRecords {
Bogdan Timofte authored 2 weeks ago
803
            updateDataGroupRecord(index: index, ah: record.ah, wh: record.wh)
Bogdan Timofte authored 2 weeks ago
804
        }
Bogdan Timofte authored 2 weeks ago
805
        setIfChanged(\.temperatureCelsius, to: snapshot.temperatureCelsius)
806
        setIfChanged(\.usbPlusVoltage, to: snapshot.usbPlusVoltage)
807
        setIfChanged(\.usbMinusVoltage, to: snapshot.usbMinusVoltage)
Bogdan Timofte authored 2 weeks ago
808
    }
Bogdan Timofte authored 2 weeks ago
809

            
810
    private func inferTC66ActiveDataGroup(from snapshot: TC66Snapshot) {
811
        let candidate = snapshot.dataGroupRecords.compactMap { entry -> (UInt8, Double)? in
812
            let index = entry.key
813
            let record = entry.value
814
            guard let previous = dataGroupRecords[index] else { return nil }
815
            let deltaAH = max(record.ah - previous.ah, 0)
816
            let deltaWH = max(record.wh - previous.wh, 0)
817
            let score = deltaAH + deltaWH
818
            guard score > 0 else { return nil }
819
            return (UInt8(index), score)
820
        }
821
        .max { lhs, rhs in lhs.1 < rhs.1 }
822

            
823
        if let candidate {
824
            selectedDataGroup = candidate.0
825
            hasObservedActiveDataGroup = true
826
        }
827
    }
828

            
829
    private func updateChargeRecord(at timestamp: Date) {
830
        switch chargeRecordState {
831
        case .waitingForStart:
832
            guard current > chargeRecordStopThreshold else { return }
833
            chargeRecordState = .active
834
            chargeRecordStartTimestamp = timestamp
835
            chargeRecordEndTimestamp = timestamp
836
            chargeRecordLastTimestamp = timestamp
837
            chargeRecordLastCurrent = current
838
            chargeRecordLastPower = power
839
        case .active:
840
            if let lastTimestamp = chargeRecordLastTimestamp {
841
                let deltaSeconds = max(timestamp.timeIntervalSince(lastTimestamp), 0)
842
                chargeRecordAH += chargeRecordLastCurrent * deltaSeconds / 3600
843
                chargeRecordWH += chargeRecordLastPower * deltaSeconds / 3600
844
                chargeRecordDuration += deltaSeconds
845
            }
846
            chargeRecordEndTimestamp = timestamp
847
            chargeRecordLastTimestamp = timestamp
848
            chargeRecordLastCurrent = current
849
            chargeRecordLastPower = power
850
            if current <= chargeRecordStopThreshold {
851
                chargeRecordState = .completed
852
            }
853
        case .completed:
854
            break
855
        }
856
    }
857

            
858
    func resetChargeRecord() {
859
        chargeRecordAH = 0
860
        chargeRecordWH = 0
861
        chargeRecordDuration = 0
862
        chargeRecordState = .waitingForStart
863
        chargeRecordStartTimestamp = nil
864
        chargeRecordEndTimestamp = nil
865
        chargeRecordLastTimestamp = nil
866
        chargeRecordLastCurrent = 0
867
        chargeRecordLastPower = 0
Bogdan Timofte authored 2 weeks ago
868
        objectWillChange.send()
Bogdan Timofte authored 2 weeks ago
869
    }
870

            
871
    func resetChargeRecordGraph() {
872
        let cutoff = Date()
873
        resetChargeRecord()
874
        measurements.trim(before: cutoff)
875
    }
Bogdan Timofte authored 2 weeks ago
876

            
877
    func nextScreen() {
878
        switch model {
879
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
880
            commandQueue.append(UMProtocol.nextScreen)
Bogdan Timofte authored 2 weeks ago
881
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
882
            commandQueue.append(UMProtocol.nextScreen)
Bogdan Timofte authored 2 weeks ago
883
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
884
            commandQueue.append(TC66Protocol.nextPage)
Bogdan Timofte authored 2 weeks ago
885
        }
886
    }
887

            
888
    func rotateScreen() {
889
        switch model {
890
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
891
            commandQueue.append(UMProtocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
892
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
893
            commandQueue.append(UMProtocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
894
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
895
            commandQueue.append(TC66Protocol.rotateScreen)
Bogdan Timofte authored 2 weeks ago
896
        }
897
    }
898

            
899
    func previousScreen() {
900
        switch model {
901
        case .UM25C:
Bogdan Timofte authored 2 weeks ago
902
            commandQueue.append(UMProtocol.previousScreen)
Bogdan Timofte authored 2 weeks ago
903
        case .UM34C:
Bogdan Timofte authored 2 weeks ago
904
            commandQueue.append(UMProtocol.previousScreen)
Bogdan Timofte authored 2 weeks ago
905
        case .TC66C:
Bogdan Timofte authored 2 weeks ago
906
            commandQueue.append(TC66Protocol.previousPage)
Bogdan Timofte authored 2 weeks ago
907
        }
908
    }
909

            
910
    func clear() {
Bogdan Timofte authored 2 weeks ago
911
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
912
        noteInitiatedVolatileMemoryResetIfNeeded(for: selectedDataGroup)
Bogdan Timofte authored 2 weeks ago
913
        commandQueue.append(UMProtocol.clearCurrentGroup)
Bogdan Timofte authored 2 weeks ago
914
    }
915

            
916
    func clear(group id: UInt8) {
Bogdan Timofte authored 2 weeks ago
917
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
918
        commandQueue.append(UMProtocol.selectDataGroup(id))
Bogdan Timofte authored 2 weeks ago
919
        noteInitiatedVolatileMemoryResetIfNeeded(for: id)
920
        commandQueue.append(UMProtocol.clearCurrentGroup)
Bogdan Timofte authored 2 weeks ago
921
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
Bogdan Timofte authored 2 weeks ago
922
    }
923

            
924
    func selectDataGroup ( id: UInt8) {
Bogdan Timofte authored 2 weeks ago
925
        guard supportsDataGroupCommands else { return }
Bogdan Timofte authored 2 weeks ago
926
        track("\(name) - \(id)")
927
        selectedDataGroup = id
Bogdan Timofte authored 2 weeks ago
928
        objectWillChange.send()
Bogdan Timofte authored 2 weeks ago
929
        commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
Bogdan Timofte authored 2 weeks ago
930
    }
931

            
932
    private func setSceeenBrightness ( to value: UInt8) {
933
        track("\(name) - \(value)")
Bogdan Timofte authored 2 weeks ago
934
        guard supportsUMSettings else { return }
Bogdan Timofte authored 2 weeks ago
935
        commandQueue.append(UMProtocol.setScreenBrightness(value))
Bogdan Timofte authored 2 weeks ago
936
    }
937
    private func setScreenSaverTimeout ( to value: UInt8) {
938
        track("\(name) - \(value)")
Bogdan Timofte authored 2 weeks ago
939
        guard supportsUMSettings else { return }
Bogdan Timofte authored 2 weeks ago
940
        commandQueue.append(UMProtocol.setScreenSaverTimeout(value))
Bogdan Timofte authored 2 weeks ago
941
    }
942
    func setrecordingTreshold ( to value: UInt8) {
Bogdan Timofte authored 2 weeks ago
943
        guard supportsRecordingThreshold else { return }
Bogdan Timofte authored 2 weeks ago
944
        commandQueue.append(UMProtocol.setRecordingThreshold(value))
Bogdan Timofte authored 2 weeks ago
945
    }
946

            
947
    /**
948
     Connect to meter.
949
     1. It calls BluetoothSerial.connect
950
     */
951
    func connect() {
952
        enableAutoConnect = true
953
        btSerial.connect()
954
    }
955

            
956
    /**
957
     Disconnect from meter.
958
        It calls BluetoothSerial.disconnect
959
     */
960
    func disconnect() {
961
        enableAutoConnect = false
962
        btSerial.disconnect()
963
    }
964
}
965

            
966
extension Meter : SerialPortDelegate {
967

            
968
    func opertionalStateChanged(to serialPortOperationalState: BluetoothSerial.OperationalState) {
Bogdan Timofte authored 2 weeks ago
969
        let applyStateChange = {
970
            self.lastSeen = Date()
971
            switch serialPortOperationalState {
972
            case .peripheralNotConnected:
973
                self.operationalState = .peripheralNotConnected
974
            case .peripheralConnectionPending:
975
                self.operationalState = .peripheralConnectionPending
976
            case .peripheralConnected:
977
                self.operationalState = .peripheralConnected
978
            case .peripheralReady:
979
                self.operationalState = .peripheralReady
980
            }
981
        }
982

            
983
        if Thread.isMainThread {
984
            applyStateChange()
985
        } else {
986
            DispatchQueue.main.async(execute: applyStateChange)
Bogdan Timofte authored 2 weeks ago
987
        }
988
    }
989

            
990
    func didReceiveData(_ data: Data) {
Bogdan Timofte authored 2 weeks ago
991
        let applyData = {
992
            self.lastSeen = Date()
Bogdan Timofte authored 2 weeks ago
993
            if self.operationalState < .comunicating {
994
                self.operationalState = .comunicating
995
            }
Bogdan Timofte authored 2 weeks ago
996
            self.parseData(from: data)
997
        }
998

            
999
        if Thread.isMainThread {
1000
            applyData()
1001
        } else {
1002
            DispatchQueue.main.async(execute: applyData)
1003
        }
Bogdan Timofte authored 2 weeks ago
1004
    }
1005
}