Newer Older
1096 lines | 39.346kb
Bogdan Timofte authored 2 months ago
1
//
2
//  DataStore.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 03/03/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8

            
9
import SwiftUI
10
import Combine
11
import CoreBluetooth
Bogdan Timofte authored a month ago
12
import CoreData
13
import UserNotifications
Bogdan Timofte authored 2 months ago
14

            
Bogdan Timofte authored a month ago
15
struct BatteryCheckpointPlausibilityWarning: Identifiable, Hashable {
16
    let title: String
17
    let message: String
18

            
19
    var id: String {
20
        "\(title)\n\(message)"
21
    }
22
}
23

            
Bogdan Timofte authored 2 months ago
24
final class AppData : ObservableObject {
Bogdan Timofte authored 2 months ago
25
    struct MeterSummary: Identifiable {
Bogdan Timofte authored 2 months ago
26
        let macAddress: String
27
        let displayName: String
28
        let modelSummary: String
29
        let advertisedName: String?
30
        let lastSeen: Date?
31
        let lastConnected: Date?
32
        let meter: Meter?
33

            
34
        var id: String {
35
            macAddress
36
        }
37
    }
38

            
Bogdan Timofte authored 2 months ago
39
    private var bluetoothManagerNotification: AnyCancellable?
Bogdan Timofte authored 2 months ago
40
    private var meterStoreObserver: AnyCancellable?
41
    private var meterStoreCloudObserver: AnyCancellable?
Bogdan Timofte authored a month ago
42
    private var chargeInsightsStoreObserver: AnyCancellable?
43
    private var chargeInsightsRemoteObserver: AnyCancellable?
Bogdan Timofte authored a month ago
44
    private var chargerStandbyPowerStoreObserver: AnyCancellable?
Bogdan Timofte authored 2 months ago
45
    private let meterStore = MeterNameStore.shared
Bogdan Timofte authored a month ago
46
    private var chargeInsightsStore: ChargeInsightsStore?
Bogdan Timofte authored a month ago
47
    private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
Bogdan Timofte authored a month ago
48
    private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
Bogdan Timofte authored 2 months ago
49

            
Bogdan Timofte authored 2 months ago
50
    init() {
Bogdan Timofte authored 2 months ago
51
        bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
Bogdan Timofte authored 2 months ago
52
            self?.scheduleObjectWillChange()
Bogdan Timofte authored 2 months ago
53
        }
Bogdan Timofte authored 2 months ago
54
        meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange)
55
            .receive(on: DispatchQueue.main)
56
            .sink { [weak self] _ in
57
                self?.refreshMeterMetadata()
58
            }
59
        meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange)
60
            .receive(on: DispatchQueue.main)
61
            .sink { [weak self] _ in
62
                self?.scheduleObjectWillChange()
63
            }
Bogdan Timofte authored a month ago
64
        chargerStandbyPowerStoreObserver = NotificationCenter.default.publisher(for: .chargerStandbyPowerStoreDidChange)
65
            .receive(on: DispatchQueue.main)
66
            .sink { [weak self] _ in
67
                self?.reloadChargedDevices()
68
            }
Bogdan Timofte authored 2 months ago
69
    }
Bogdan Timofte authored 2 months ago
70

            
Bogdan Timofte authored 2 months ago
71
    let bluetoothManager = BluetoothManager()
Bogdan Timofte authored 2 months ago
72

            
Bogdan Timofte authored 2 months ago
73
    @Published var enableRecordFeature: Bool = true
Bogdan Timofte authored 2 months ago
74

            
Bogdan Timofte authored 2 months ago
75
    @Published var meters: [UUID:Meter] = [UUID:Meter]()
Bogdan Timofte authored a month ago
76
    @Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
Bogdan Timofte authored a month ago
77
    @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:]
Bogdan Timofte authored a month ago
78

            
79
    var deviceSummaries: [ChargedDeviceSummary] {
80
        chargedDevices.filter { !$0.isCharger }
81
    }
82

            
83
    var chargerSummaries: [ChargedDeviceSummary] {
84
        chargedDevices.filter { $0.isCharger }
85
    }
Bogdan Timofte authored 2 months ago
86

            
87
    var cloudAvailability: MeterNameStore.CloudAvailability {
88
        meterStore.currentCloudAvailability
89
    }
90

            
Bogdan Timofte authored a month ago
91
    func activateChargeInsights(context: NSManagedObjectContext) {
92
        guard chargeInsightsStore == nil else {
93
            return
94
        }
95

            
96
        context.automaticallyMergesChangesFromParent = true
Bogdan Timofte authored a month ago
97
        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
Bogdan Timofte authored a month ago
98
        chargeInsightsStore = ChargeInsightsStore(context: context)
99

            
100
        chargeInsightsStoreObserver = NotificationCenter.default.publisher(
101
            for: .NSManagedObjectContextObjectsDidChange,
102
            object: context
103
        )
104
        .receive(on: DispatchQueue.main)
105
        .sink { [weak self] _ in
106
            self?.reloadChargedDevices()
107
        }
108

            
109
        chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
110
            for: .NSPersistentStoreRemoteChange,
111
            object: nil
112
        )
113
        .receive(on: DispatchQueue.main)
114
        .sink { [weak self] _ in
115
            self?.reloadChargedDevices()
116
        }
117

            
118
        chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
119
        reloadChargedDevices()
120
    }
121

            
Bogdan Timofte authored 2 months ago
122
    func meterName(for macAddress: String) -> String? {
123
        meterStore.name(for: macAddress)
124
    }
125

            
126
    func setMeterName(_ name: String, for macAddress: String) {
127
        meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
128
    }
129

            
130
    func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
131
        let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
132
        return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
133
    }
134

            
135
    func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
136
        meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
Bogdan Timofte authored 2 months ago
137
    }
Bogdan Timofte authored 2 months ago
138

            
Bogdan Timofte authored 2 months ago
139
    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
140
        meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
Bogdan Timofte authored 2 months ago
141
    }
142

            
143
    func noteMeterSeen(at date: Date, macAddress: String) {
144
        meterStore.noteLastSeen(date, for: macAddress)
145
    }
146

            
147
    func noteMeterConnected(at date: Date, macAddress: String) {
148
        meterStore.noteLastConnected(date, for: macAddress)
149
    }
150

            
151
    func lastSeen(for macAddress: String) -> Date? {
152
        meterStore.lastSeen(for: macAddress)
153
    }
154

            
155
    func lastConnected(for macAddress: String) -> Date? {
156
        meterStore.lastConnected(for: macAddress)
157
    }
158

            
Bogdan Timofte authored a month ago
159
    func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
160
        chargedDevices.first(where: { $0.id == id })
161
    }
162

            
Bogdan Timofte authored a month ago
163
    func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
164
        for chargedDevice in chargedDevices {
165
            if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
166
                return session
167
            }
168
        }
169
        return nil
170
    }
171

            
Bogdan Timofte authored a month ago
172
    func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
173
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
174
        return chargedDevices.filter { chargedDevice in
175
            guard chargedDevice.isCharger == false else {
176
                return false
177
            }
178
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
179
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
180
        }
181
    }
182

            
183
    func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] {
184
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
185
        return chargedDevices.filter { chargedDevice in
186
            guard chargedDevice.isCharger else {
187
                return false
188
            }
189
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
190
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
191
        }
192
    }
193

            
194
    func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
195
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
196

            
197
        if let activeSession = activeChargeSessionSummary(for: normalizedMAC),
198
           let liveDevice = chargedDevices.first(where: {
199
               $0.id == activeSession.chargedDeviceID && $0.isCharger == false
200
           }) {
201
            return liveDevice
202
        }
203

            
204
        return chargedDevices.first(where: {
205
            $0.isCharger == false && $0.lastAssociatedMeterMAC == normalizedMAC
206
        })
207
    }
208

            
209
    func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
210
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
211

            
212
        if let activeSession = activeChargeSessionSummary(for: normalizedMAC),
213
           let chargerID = activeSession.chargerID,
214
           let liveCharger = chargedDevices.first(where: {
215
               $0.id == chargerID && $0.isCharger
216
           }) {
217
            return liveCharger
218
        }
219

            
220
        return chargedDevices.first(where: {
221
            $0.isCharger && $0.lastAssociatedMeterMAC == normalizedMAC
222
        })
223
    }
224

            
225
    func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
226
        chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
227
    }
228

            
Bogdan Timofte authored a month ago
229
    func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
230
        activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)]
231
    }
232

            
233
    @discardableResult
234
    func startChargerStandbyMeasurement(for chargerID: UUID, on meter: Meter) -> Bool {
235
        guard chargedDeviceSummary(id: chargerID)?.isCharger == true else {
236
            return false
237
        }
238

            
239
        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
240
        if let existingSession = activeChargerStandbySessions[normalizedMAC] {
241
            return existingSession.chargerID == chargerID
242
        }
243

            
244
        let session = ChargerStandbyPowerMonitorSession(chargerID: chargerID, meter: meter)
245
        session.onChange = { [weak self] in
246
            self?.scheduleObjectWillChange()
247
        }
248
        session.onStabilized = { [weak self, weak session] in
249
            guard let self, let session else { return }
250
            self.notifyChargerStandbyMeasurementReady(for: session)
251
        }
252

            
253
        activeChargerStandbySessions[normalizedMAC] = session
254
        session.start()
255

            
256
        // Starting a standby run on an available meter should also initiate the BLE link.
257
        if meter.operationalState == .peripheralNotConnected {
258
            meter.connect()
259
        }
260

            
261
        scheduleObjectWillChange()
262
        return true
263
    }
264

            
265
    @discardableResult
266
    func finishChargerStandbyMeasurement(for meterMACAddress: String, save: Bool) -> Bool {
267
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
268
        guard let session = activeChargerStandbySessions[normalizedMAC] else {
269
            return false
270
        }
271

            
272
        session.stop()
273

            
274
        guard save else {
275
            activeChargerStandbySessions[normalizedMAC] = nil
276
            scheduleObjectWillChange()
277
            return true
278
        }
279

            
280
        guard let summary = session.makeSummary() else {
281
            scheduleObjectWillChange()
282
            return false
283
        }
284

            
285
        let didSave = chargerStandbyPowerStore.save(summary)
286
        if didSave {
287
            activeChargerStandbySessions[normalizedMAC] = nil
288
            reloadChargedDevices()
289
        } else {
290
            scheduleObjectWillChange()
291
        }
292

            
293
        return didSave
294
    }
295

            
Bogdan Timofte authored a month ago
296
    @discardableResult
297
    func deleteChargerStandbyMeasurement(id: UUID, chargerID: UUID) -> Bool {
298
        let didDelete = chargerStandbyPowerStore.removeMeasurement(id: id, chargerID: chargerID)
299
        if didDelete {
300
            reloadChargedDevices()
301
        } else {
302
            scheduleObjectWillChange()
303
        }
304
        return didDelete
305
    }
306

            
Bogdan Timofte authored a month ago
307
    @discardableResult
Bogdan Timofte authored a month ago
308
    func createDevice(
Bogdan Timofte authored a month ago
309
        name: String,
310
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
311
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
312
        supportsWiredCharging: Bool,
313
        supportsWirelessCharging: Bool,
314
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
315
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
316
        notes: String?,
317
        meterMACAddress: String?
318
    ) -> Bool {
Bogdan Timofte authored a month ago
319
        let didSave = chargeInsightsStore?.createDevice(
Bogdan Timofte authored a month ago
320
            name: name,
321
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
322
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
323
            supportsWiredCharging: supportsWiredCharging,
324
            supportsWirelessCharging: supportsWirelessCharging,
325
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
326
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
327
            notes: notes,
328
            assignTo: meterMACAddress
329
        ) ?? false
330

            
331
        if didSave {
332
            reloadChargedDevices()
333
        }
334

            
335
        return didSave
336
    }
337

            
338
    @discardableResult
Bogdan Timofte authored a month ago
339
    func createCharger(
340
        name: String,
341
        notes: String?,
342
        meterMACAddress: String?
343
    ) -> Bool {
344
        let didSave = chargeInsightsStore?.createCharger(
345
            name: name,
346
            notes: notes,
347
            assignTo: meterMACAddress
348
        ) ?? false
349

            
350
        if didSave {
351
            reloadChargedDevices()
352
        }
353

            
354
        return didSave
355
    }
356

            
357
    @discardableResult
358
    func updateDevice(
Bogdan Timofte authored a month ago
359
        id: UUID,
360
        name: String,
361
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
362
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
363
        supportsWiredCharging: Bool,
364
        supportsWirelessCharging: Bool,
365
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
366
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
367
        notes: String?
368
    ) -> Bool {
Bogdan Timofte authored a month ago
369
        let didSave = chargeInsightsStore?.updateDevice(
Bogdan Timofte authored a month ago
370
            id: id,
371
            name: name,
372
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
373
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
374
            supportsWiredCharging: supportsWiredCharging,
375
            supportsWirelessCharging: supportsWirelessCharging,
376
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
377
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
378
            notes: notes
379
        ) ?? false
380

            
381
        if didSave {
382
            reloadChargedDevices()
383
        }
384

            
385
        return didSave
386
    }
387

            
388
    @discardableResult
Bogdan Timofte authored a month ago
389
    func updateCharger(
390
        id: UUID,
391
        name: String,
392
        notes: String?
393
    ) -> Bool {
394
        let didSave = chargeInsightsStore?.updateCharger(
395
            id: id,
396
            name: name,
397
            notes: notes
Bogdan Timofte authored a month ago
398
        ) ?? false
399

            
400
        if didSave {
401
            reloadChargedDevices()
402
        }
403

            
404
        return didSave
405
    }
406

            
407
    @discardableResult
408
    func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool {
409
        let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false
410
        if didSave {
411
            reloadChargedDevices()
412
        }
413
        return didSave
414
    }
415

            
416
    @discardableResult
417
    func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
418
        let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false
419
        if didSave {
420
            reloadChargedDevices()
421
        }
422
        return didSave
423
    }
424

            
Bogdan Timofte authored a month ago
425
    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
426
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
427
            return
428
        }
429
        guard activeSession.status == .active else {
430
            return
431
        }
432
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
433
    }
434

            
Bogdan Timofte authored a month ago
435
    @discardableResult
Bogdan Timofte authored a month ago
436
    func startChargeSession(
437
        for meter: Meter,
438
        chargedDeviceID: UUID,
439
        chargerID: UUID?,
440
        chargingTransportMode: ChargingTransportMode,
441
        chargingStateMode: ChargingStateMode,
442
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
443
        initialBatteryPercent: Double?,
444
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
445
    ) -> Bool {
Bogdan Timofte authored a month ago
446
        meter.resetMeterCountersForNewSession()
447

            
Bogdan Timofte authored a month ago
448
        guard let snapshot = meter.chargingMonitorSnapshot else {
449
            return false
450
        }
451

            
Bogdan Timofte authored a month ago
452
        let didSave = chargeInsightsStore?.startSession(
453
            for: snapshot,
454
            chargedDeviceID: chargedDeviceID,
455
            chargerID: chargerID,
456
            chargingTransportMode: chargingTransportMode,
457
            chargingStateMode: chargingStateMode,
458
            autoStopEnabled: autoStopEnabled,
Bogdan Timofte authored a month ago
459
            initialBatteryPercent: initialBatteryPercent,
460
            startsFromFlatBattery: startsFromFlatBattery
Bogdan Timofte authored a month ago
461
        ) ?? false
462
        if didSave {
463
            reloadChargedDevices()
Bogdan Timofte authored a month ago
464
            meter.resetChargeRecordGraph()
465
            if let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description),
466
               meter.supportsRecordingThreshold,
467
               activeSession.stopThresholdAmps > 0 {
468
                meter.recordingTreshold = activeSession.stopThresholdAmps
469
            }
470
            restoreChargeMonitoringStateIfNeeded(for: meter)
Bogdan Timofte authored a month ago
471
        }
472
        return didSave
473
    }
474

            
475
    @discardableResult
476
    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
477
        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
478
        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
479
        if didSave {
480
            reloadChargedDevices()
481
        }
482
        return didSave
483
    }
484

            
485
    @discardableResult
486
    func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
487
        let snapshot = meter?.chargingMonitorSnapshot
488
        let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
489
        if didSave {
490
            reloadChargedDevices()
491
        }
492
        return didSave
493
    }
494

            
495
    @discardableResult
496
    func stopChargeSession(
497
        sessionID: UUID,
498
        finalBatteryPercent: Double,
499
        label: String? = "Final"
500
    ) -> Bool {
501
        let didSave = chargeInsightsStore?.stopSession(
502
            id: sessionID,
503
            finalBatteryPercent: finalBatteryPercent,
504
            label: label
505
        ) ?? false
Bogdan Timofte authored a month ago
506
        if didSave {
507
            reloadChargedDevices()
508
        }
509
        return didSave
510
    }
511

            
512
    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
513
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
514
            return
515
        }
516

            
517
        if chargeInsightsStore?.observe(snapshot: snapshot) == true {
518
            reloadChargedDevices()
519
        }
520
    }
521

            
522
    @discardableResult
523
    func addBatteryCheckpoint(percent: Double, label: String?, for meter: Meter) -> Bool {
Bogdan Timofte authored a month ago
524
        observeChargeSnapshot(from: meter)
525

            
526
        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
527
        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
528
        let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
529

            
Bogdan Timofte authored a month ago
530
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
531
            percent: percent,
532
            label: label,
Bogdan Timofte authored a month ago
533
            for: meter.btSerial.macAddress.description,
534
            measuredEnergyWh: checkpointEnergyWh,
535
            measuredChargeAh: checkpointChargeAh
Bogdan Timofte authored a month ago
536
        ) ?? false
537

            
538
        if didSave {
539
            reloadChargedDevices()
540
        }
541

            
542
        return didSave
543
    }
544

            
545
    @discardableResult
546
    func addBatteryCheckpoint(percent: Double, label: String?, for sessionID: UUID) -> Bool {
547
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
548
            percent: percent,
549
            label: label,
550
            for: sessionID
551
        ) ?? false
552

            
553
        if didSave {
554
            reloadChargedDevices()
555
        }
556

            
557
        return didSave
558
    }
559

            
Bogdan Timofte authored a month ago
560
    func batteryCheckpointPlausibilityWarning(
561
        percent: Double,
562
        for sessionID: UUID
563
    ) -> BatteryCheckpointPlausibilityWarning? {
564
        guard let session = chargeSessionSummary(id: sessionID) else {
565
            return nil
566
        }
567
        return batteryCheckpointPlausibilityWarning(percent: percent, for: session)
568
    }
569

            
570
    @discardableResult
571
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
572
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
573
            id: checkpointID,
574
            from: sessionID
575
        ) ?? false
576

            
577
        if didDelete {
578
            reloadChargedDevices()
579
        }
580

            
581
        return didDelete
582
    }
583

            
Bogdan Timofte authored a month ago
584
    @discardableResult
585
    func flushChargeInsights() -> Bool {
586
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
587
        reloadChargedDevices()
588
        return didSave
589
    }
590

            
591
    @discardableResult
592
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
593
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
594
            return false
595
        }
596
        return setTargetBatteryPercent(percent, for: activeSession.id)
597
    }
598

            
599
    @discardableResult
600
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
601
        if percent != nil {
602
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
603
        }
604

            
605
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
606
        if didSave {
607
            reloadChargedDevices()
608
        }
609
        return didSave
610
    }
611

            
612
    @discardableResult
613
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
614
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
615
        if didSave {
616
            reloadChargedDevices()
617
        }
618
        return didSave
619
    }
620

            
621
    @discardableResult
622
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
623
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
624
        if didSave {
625
            reloadChargedDevices()
626
        }
627
        return didSave
628
    }
629

            
630
    @discardableResult
631
    func deleteChargeSession(sessionID: UUID) -> Bool {
632
        let deletedSession = chargedDevices
633
            .flatMap(\.sessions)
634
            .first(where: { $0.id == sessionID })
635

            
636
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
637
        guard didDelete else {
638
            return false
639
        }
640

            
Bogdan Timofte authored a month ago
641
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
642
           let meterMACAddress = deletedSession?.meterMACAddress,
643
           let liveMeter = meter(for: meterMACAddress) {
644
            liveMeter.resetChargeRecord()
645
        }
646

            
647
        reloadChargedDevices()
648
        return true
649
    }
650

            
651
    @discardableResult
652
    func deleteChargedDevice(id: UUID) -> Bool {
653
        let deletedDevice = chargedDeviceSummary(id: id)
654
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
655
        guard didDelete else {
656
            return false
657
        }
658

            
Bogdan Timofte authored a month ago
659
        if deletedDevice?.isCharger == true {
660
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
661
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
662
                session.stop()
663
                activeChargerStandbySessions[meterMACAddress] = nil
664
            }
665
        }
666

            
Bogdan Timofte authored a month ago
667
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
668
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
669
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
670
           let liveMeter = meter(for: meterMACAddress) {
671
            liveMeter.resetChargeRecord()
672
        }
673

            
674
        reloadChargedDevices()
675
        return true
676
    }
677

            
678
    @discardableResult
679
    func createKnownMeter(
680
        macAddress: String,
681
        customName: String?,
682
        modelName: String,
683
        advertisedName: String?
684
    ) -> Bool {
685
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
686
        guard Self.isValidMACAddress(normalizedMAC) else {
687
            return false
688
        }
689

            
690
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
691
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
692
            setMeterName(customName, for: normalizedMAC)
693
        }
694
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
695
        return true
696
    }
697

            
698
    @discardableResult
699
    func deleteMeter(macAddress: String) -> Bool {
700
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
701
        guard Self.isValidMACAddress(normalizedMAC) else {
702
            return false
703
        }
704

            
705
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
706
            meter.disconnect()
707
        }
708
        meters = meters.filter { element in
709
            element.value.btSerial.macAddress.description != normalizedMAC
710
        }
711

            
712
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
713
        if didDelete {
714
            scheduleObjectWillChange()
715
        }
716
        return didDelete
717
    }
718

            
Bogdan Timofte authored 2 months ago
719
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored 2 months ago
720
        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
721
        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
722
        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
723

            
724
        return macAddresses.map { macAddress in
725
            let liveMeter = liveMetersByMAC[macAddress]
726
            let record = recordsByMAC[macAddress]
727

            
Bogdan Timofte authored 2 months ago
728
            return MeterSummary(
Bogdan Timofte authored 2 months ago
729
                macAddress: macAddress,
Bogdan Timofte authored 2 months ago
730
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
Bogdan Timofte authored 2 months ago
731
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
Bogdan Timofte authored 2 months ago
732
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
733
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
734
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
735
                meter: liveMeter
736
            )
737
        }
738
        .sorted { lhs, rhs in
Bogdan Timofte authored 2 months ago
739
            if lhs.meter != nil && rhs.meter == nil {
740
                return true
741
            }
742
            if lhs.meter == nil && rhs.meter != nil {
743
                return false
744
            }
Bogdan Timofte authored 2 months ago
745
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
746
            if byName != .orderedSame {
747
                return byName == .orderedAscending
748
            }
749
            return lhs.macAddress < rhs.macAddress
750
        }
751
    }
752

            
Bogdan Timofte authored 2 months ago
753
    private func scheduleObjectWillChange() {
754
        DispatchQueue.main.async { [weak self] in
755
            self?.objectWillChange.send()
756
        }
757
    }
Bogdan Timofte authored 2 months ago
758

            
Bogdan Timofte authored a month ago
759
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
760
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
761
        chargedDevices = (chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
762
            chargedDevice.withStandbyPowerMeasurements(
763
                standbyMeasurementsByChargerID[chargedDevice.id] ?? []
764
            )
765
        }
Bogdan Timofte authored a month ago
766
        chargeNotificationCoordinator.process(chargedDevices: deviceSummaries)
767
        for meter in meters.values {
768
            restoreChargeMonitoringStateIfNeeded(for: meter)
769
        }
770
    }
771

            
772
    private func meter(for meterMACAddress: String) -> Meter? {
773
        meters.values.first { meter in
774
            meter.btSerial.macAddress.description == meterMACAddress
775
        }
776
    }
777

            
Bogdan Timofte authored 2 months ago
778
    private func refreshMeterMetadata() {
779
        DispatchQueue.main.async { [weak self] in
780
            guard let self else { return }
781
            var didUpdateAnyMeter = false
782
            for meter in self.meters.values {
783
                let mac = meter.btSerial.macAddress.description
784
                let displayName = self.meterName(for: mac) ?? mac
785
                if meter.name != displayName {
786
                    meter.updateNameFromStore(displayName)
787
                    didUpdateAnyMeter = true
788
                }
789

            
790
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
791
                meter.reloadTemperatureUnitPreference()
792
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
793
                    didUpdateAnyMeter = true
794
                }
795
            }
796

            
797
            if didUpdateAnyMeter {
798
                self.scheduleObjectWillChange()
799
            }
800
        }
801
    }
Bogdan Timofte authored a month ago
802

            
Bogdan Timofte authored a month ago
803
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
804
        guard let charger = chargedDeviceSummary(id: session.chargerID),
805
              let statistics = session.statistics else {
806
            return
807
        }
808

            
809
        let content = UNMutableNotificationContent()
810
        content.title = "Standby baseline stabilised"
811
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
812
        content.sound = .default
813
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
814

            
815
        let request = UNNotificationRequest(
816
            identifier: "charger-standby-\(session.id.uuidString)",
817
            content: content,
818
            trigger: nil
819
        )
820
        UNUserNotificationCenter.current().add(request)
821
        scheduleObjectWillChange()
822
    }
823

            
Bogdan Timofte authored a month ago
824
    private func batteryCheckpointPlausibilityWarning(
825
        percent: Double,
826
        for session: ChargeSessionSummary
827
    ) -> BatteryCheckpointPlausibilityWarning? {
828
        guard percent.isFinite, percent >= 0, percent <= 100 else {
829
            return nil
830
        }
831

            
832
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
833
            if lhs.timestamp != rhs.timestamp {
834
                return lhs.timestamp < rhs.timestamp
835
            }
836
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
837
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
838
            }
839
            return lhs.id.uuidString < rhs.id.uuidString
840
        }
841

            
842
        if let lastCheckpoint = sortedCheckpoints.last,
843
           percent < lastCheckpoint.batteryPercent - 1.5 {
844
            return BatteryCheckpointPlausibilityWarning(
845
                title: "Checkpoint Goes Backwards",
846
                message: "The latest checkpoint is \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.timestamp.format()). A new value of \(percent.format(decimalDigits: 0))% is unexpectedly lower while the session is still charging."
847
            )
848
        }
849

            
850
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
851
              let prediction = chargedDevice.batteryLevelPrediction(for: session)
852
        else {
853
            return nil
854
        }
855

            
856
        let predictionGap = percent - prediction.predictedPercent
857
        guard abs(predictionGap) >= 4 else {
858
            return nil
859
        }
860

            
861
        let direction = predictionGap > 0 ? "above" : "below"
862
        let gapText = abs(predictionGap).format(decimalDigits: 0)
863
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
864

            
865
        if let lastCheckpoint = sortedCheckpoints.last {
866
            let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh
867
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
868
            return BatteryCheckpointPlausibilityWarning(
869
                title: "Checkpoint Looks Implausible",
870
                message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Since the last checkpoint at \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))%, only \(energyDeltaWh.format(decimalDigits: 2)) Wh were added."
871
            )
872
        }
873

            
874
        return BatteryCheckpointPlausibilityWarning(
875
            title: "Checkpoint Looks Implausible",
876
            message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Save it only if the device gauge really jumped that much."
877
        )
878
    }
Bogdan Timofte authored a month ago
879

            
880
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
881
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
882
        guard session.status.isOpen else {
883
            return storedEnergyWh
884
        }
885

            
886
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
887
            return storedEnergyWh
888
        }
889

            
890
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
891
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
892
        }
893

            
894
        return storedEnergyWh
895
    }
896

            
897
    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
898
        let storedChargeAh = session.measuredChargeAh
899
        guard session.status.isOpen else {
900
            return storedChargeAh
901
        }
902

            
903
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
904
            return storedChargeAh
905
        }
906

            
907
        if let baselineChargeAh = session.meterChargeBaselineAh {
908
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
909
        }
910

            
911
        return storedChargeAh
912
    }
Bogdan Timofte authored 2 months ago
913
}
Bogdan Timofte authored 2 months ago
914

            
915
extension AppData.MeterSummary {
916
    var tint: Color {
917
        switch modelSummary {
918
        case "UM25C":
919
            return .blue
920
        case "UM34C":
921
            return .yellow
922
        case "TC66C":
923
            return Model.TC66C.color
924
        default:
925
            return .secondary
926
        }
927
    }
928
}
Bogdan Timofte authored 2 months ago
929

            
Bogdan Timofte authored a month ago
930
extension AppData {
Bogdan Timofte authored 2 months ago
931
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
932
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
933
            return liveName
934
        }
935
        if let customName = record?.customName {
936
            return customName
937
        }
938
        if let advertisedName = record?.advertisedName {
939
            return advertisedName
940
        }
941
        if let recordModel = record?.modelName {
942
            return recordModel
943
        }
944
        if let liveModel = liveMeter?.deviceModelSummary {
945
            return liveModel
946
        }
947
        return "Meter"
948
    }
Bogdan Timofte authored a month ago
949

            
950
    static func normalizedMACAddress(_ macAddress: String) -> String {
951
        macAddress
952
            .trimmingCharacters(in: .whitespacesAndNewlines)
953
            .uppercased()
954
    }
955

            
956
    static func isValidMACAddress(_ macAddress: String) -> Bool {
957
        macAddress.range(
958
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
959
            options: .regularExpression
960
        ) != nil
961
    }
962
}
963

            
964
private final class ChargeNotificationCoordinator {
965
    private struct Payload {
966
        let id: String
967
        let title: String
968
        let body: String
969
        let threadIdentifier: String
970
    }
971

            
972
    private let notificationCenter = UNUserNotificationCenter.current()
973
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
974
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
975
    private var inFlightEventIDs: Set<String> = []
976

            
977
    func ensureAuthorizationIfNeeded() {
978
        notificationCenter.getNotificationSettings { [weak self] settings in
979
            guard settings.authorizationStatus == .notDetermined else {
980
                return
981
            }
982

            
983
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
984
                if let error {
985
                    track("Notification authorization request failed: \(error.localizedDescription)")
986
                }
987
            }
988
        }
989
    }
990

            
991
    func process(chargedDevices: [ChargedDeviceSummary]) {
992
        let now = Date()
993
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
994
            payloads(for: chargedDevice, now: now)
995
        }
996

            
997
        for payload in pendingPayloads {
998
            scheduleIfNeeded(payload)
999
        }
1000
    }
1001

            
1002
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1003
        chargedDevice.sessions.compactMap { session in
1004
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1005
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1006
               let targetBatteryPercent = session.targetBatteryPercent {
1007
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1008
                    ?? session.endBatteryPercent
1009
                    ?? targetBatteryPercent
1010

            
1011
                return Payload(
1012
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1013
                    title: "Battery target reached",
1014
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1015
                    threadIdentifier: session.id.uuidString
1016
                )
1017
            }
1018

            
1019
            if session.requiresCompletionConfirmation,
1020
               let requestedAt = session.completionConfirmationRequestedAt,
1021
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
1022
                let estimatedPercent = session.completionContradictionPercent
1023
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1024
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
1025
                let detail = estimatedPercent.map {
1026
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
1027
                } ?? ""
1028

            
1029
                return Payload(
1030
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1031
                    title: "Confirm charge completion",
1032
                    body: bodyPrefix + detail,
1033
                    threadIdentifier: session.id.uuidString
1034
                )
1035
            }
1036

            
1037
            return nil
1038
        }
1039
    }
1040

            
1041
    private func scheduleIfNeeded(_ payload: Payload) {
1042
        guard deliveredEventIDs().contains(payload.id) == false else {
1043
            return
1044
        }
1045

            
1046
        guard inFlightEventIDs.contains(payload.id) == false else {
1047
            return
1048
        }
1049

            
1050
        inFlightEventIDs.insert(payload.id)
1051

            
1052
        notificationCenter.getNotificationSettings { [weak self] settings in
1053
            guard let self else { return }
1054
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1055
                DispatchQueue.main.async {
1056
                    self.inFlightEventIDs.remove(payload.id)
1057
                }
1058
                return
1059
            }
1060

            
1061
            let content = UNMutableNotificationContent()
1062
            content.title = payload.title
1063
            content.body = payload.body
1064
            content.sound = .default
1065
            content.threadIdentifier = payload.threadIdentifier
1066

            
1067
            let request = UNNotificationRequest(
1068
                identifier: payload.id,
1069
                content: content,
1070
                trigger: nil
1071
            )
1072

            
1073
            self.notificationCenter.add(request) { error in
1074
                DispatchQueue.main.async {
1075
                    self.inFlightEventIDs.remove(payload.id)
1076
                    if let error {
1077
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1078
                        return
1079
                    }
1080
                    self.storeDeliveredEventID(payload.id)
1081
                }
1082
            }
1083
        }
1084
    }
1085

            
1086
    private func deliveredEventIDs() -> Set<String> {
1087
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1088
        return Set(values)
1089
    }
1090

            
1091
    private func storeDeliveredEventID(_ id: String) {
1092
        var values = deliveredEventIDs()
1093
        values.insert(id)
1094
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1095
    }
Bogdan Timofte authored 2 months ago
1096
}