Newer Older
1079 lines | 38.664kb
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 2 months ago
44
    private let meterStore = MeterNameStore.shared
Bogdan Timofte authored a month ago
45
    private var chargeInsightsStore: ChargeInsightsStore?
Bogdan Timofte authored a month ago
46
    private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
Bogdan Timofte authored a month ago
47
    private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
Bogdan Timofte authored 2 months ago
48

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

            
Bogdan Timofte authored 2 months ago
65
    let bluetoothManager = BluetoothManager()
Bogdan Timofte authored 2 months ago
66

            
Bogdan Timofte authored 2 months ago
67
    @Published var enableRecordFeature: Bool = true
Bogdan Timofte authored 2 months ago
68

            
Bogdan Timofte authored 2 months ago
69
    @Published var meters: [UUID:Meter] = [UUID:Meter]()
Bogdan Timofte authored a month ago
70
    @Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
Bogdan Timofte authored a month ago
71
    @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:]
Bogdan Timofte authored a month ago
72

            
73
    var deviceSummaries: [ChargedDeviceSummary] {
74
        chargedDevices.filter { !$0.isCharger }
75
    }
76

            
77
    var chargerSummaries: [ChargedDeviceSummary] {
78
        chargedDevices.filter { $0.isCharger }
79
    }
Bogdan Timofte authored 2 months ago
80

            
81
    var cloudAvailability: MeterNameStore.CloudAvailability {
82
        meterStore.currentCloudAvailability
83
    }
84

            
Bogdan Timofte authored a month ago
85
    func activateChargeInsights(context: NSManagedObjectContext) {
86
        guard chargeInsightsStore == nil else {
87
            return
88
        }
89

            
90
        context.automaticallyMergesChangesFromParent = true
Bogdan Timofte authored a month ago
91
        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
Bogdan Timofte authored a month ago
92
        chargeInsightsStore = ChargeInsightsStore(context: context)
93

            
94
        chargeInsightsStoreObserver = NotificationCenter.default.publisher(
95
            for: .NSManagedObjectContextObjectsDidChange,
96
            object: context
97
        )
98
        .receive(on: DispatchQueue.main)
99
        .sink { [weak self] _ in
100
            self?.reloadChargedDevices()
101
        }
102

            
103
        chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
104
            for: .NSPersistentStoreRemoteChange,
105
            object: nil
106
        )
107
        .receive(on: DispatchQueue.main)
108
        .sink { [weak self] _ in
109
            self?.reloadChargedDevices()
110
        }
111

            
112
        chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
113
        reloadChargedDevices()
114
    }
115

            
Bogdan Timofte authored 2 months ago
116
    func meterName(for macAddress: String) -> String? {
117
        meterStore.name(for: macAddress)
118
    }
119

            
120
    func setMeterName(_ name: String, for macAddress: String) {
121
        meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
122
    }
123

            
124
    func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
125
        let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
126
        return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
127
    }
128

            
129
    func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
130
        meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
Bogdan Timofte authored 2 months ago
131
    }
Bogdan Timofte authored 2 months ago
132

            
Bogdan Timofte authored 2 months ago
133
    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
134
        meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
Bogdan Timofte authored 2 months ago
135
    }
136

            
137
    func noteMeterSeen(at date: Date, macAddress: String) {
138
        meterStore.noteLastSeen(date, for: macAddress)
139
    }
140

            
141
    func noteMeterConnected(at date: Date, macAddress: String) {
142
        meterStore.noteLastConnected(date, for: macAddress)
143
    }
144

            
145
    func lastSeen(for macAddress: String) -> Date? {
146
        meterStore.lastSeen(for: macAddress)
147
    }
148

            
149
    func lastConnected(for macAddress: String) -> Date? {
150
        meterStore.lastConnected(for: macAddress)
151
    }
152

            
Bogdan Timofte authored a month ago
153
    func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
154
        chargedDevices.first(where: { $0.id == id })
155
    }
156

            
Bogdan Timofte authored a month ago
157
    func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
158
        for chargedDevice in chargedDevices {
159
            if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
160
                return session
161
            }
162
        }
163
        return nil
164
    }
165

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

            
177
    func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] {
178
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
179
        return chargedDevices.filter { chargedDevice in
180
            guard chargedDevice.isCharger else {
181
                return false
182
            }
183
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
184
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
185
        }
186
    }
187

            
188
    func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
189
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
190

            
191
        if let activeSession = activeChargeSessionSummary(for: normalizedMAC),
192
           let liveDevice = chargedDevices.first(where: {
193
               $0.id == activeSession.chargedDeviceID && $0.isCharger == false
194
           }) {
195
            return liveDevice
196
        }
197

            
198
        return chargedDevices.first(where: {
199
            $0.isCharger == false && $0.lastAssociatedMeterMAC == normalizedMAC
200
        })
201
    }
202

            
203
    func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
204
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
205

            
206
        if let activeSession = activeChargeSessionSummary(for: normalizedMAC),
207
           let chargerID = activeSession.chargerID,
208
           let liveCharger = chargedDevices.first(where: {
209
               $0.id == chargerID && $0.isCharger
210
           }) {
211
            return liveCharger
212
        }
213

            
214
        return chargedDevices.first(where: {
215
            $0.isCharger && $0.lastAssociatedMeterMAC == normalizedMAC
216
        })
217
    }
218

            
219
    func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
220
        chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
221
    }
222

            
Bogdan Timofte authored a month ago
223
    func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
224
        activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)]
225
    }
226

            
227
    @discardableResult
228
    func startChargerStandbyMeasurement(for chargerID: UUID, on meter: Meter) -> Bool {
229
        guard chargedDeviceSummary(id: chargerID)?.isCharger == true else {
230
            return false
231
        }
232

            
233
        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
234
        if let existingSession = activeChargerStandbySessions[normalizedMAC] {
235
            return existingSession.chargerID == chargerID
236
        }
237

            
238
        let session = ChargerStandbyPowerMonitorSession(chargerID: chargerID, meter: meter)
239
        session.onChange = { [weak self] in
240
            self?.scheduleObjectWillChange()
241
        }
242
        session.onStabilized = { [weak self, weak session] in
243
            guard let self, let session else { return }
244
            self.notifyChargerStandbyMeasurementReady(for: session)
245
        }
246

            
247
        activeChargerStandbySessions[normalizedMAC] = session
248
        session.start()
249

            
250
        // Starting a standby run on an available meter should also initiate the BLE link.
251
        if meter.operationalState == .peripheralNotConnected {
252
            meter.connect()
253
        }
254

            
255
        scheduleObjectWillChange()
256
        return true
257
    }
258

            
259
    @discardableResult
260
    func finishChargerStandbyMeasurement(for meterMACAddress: String, save: Bool) -> Bool {
261
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
262
        guard let session = activeChargerStandbySessions[normalizedMAC] else {
263
            return false
264
        }
265

            
266
        session.stop()
267

            
268
        guard save else {
269
            activeChargerStandbySessions[normalizedMAC] = nil
270
            scheduleObjectWillChange()
271
            return true
272
        }
273

            
274
        guard let summary = session.makeSummary() else {
275
            scheduleObjectWillChange()
276
            return false
277
        }
278

            
279
        let didSave = chargerStandbyPowerStore.save(summary)
280
        if didSave {
281
            activeChargerStandbySessions[normalizedMAC] = nil
282
            reloadChargedDevices()
283
        } else {
284
            scheduleObjectWillChange()
285
        }
286

            
287
        return didSave
288
    }
289

            
Bogdan Timofte authored a month ago
290
    @discardableResult
Bogdan Timofte authored a month ago
291
    func createDevice(
Bogdan Timofte authored a month ago
292
        name: String,
293
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
294
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
295
        supportsWiredCharging: Bool,
296
        supportsWirelessCharging: Bool,
297
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
298
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
299
        notes: String?,
300
        meterMACAddress: String?
301
    ) -> Bool {
Bogdan Timofte authored a month ago
302
        let didSave = chargeInsightsStore?.createDevice(
Bogdan Timofte authored a month ago
303
            name: name,
304
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
305
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
306
            supportsWiredCharging: supportsWiredCharging,
307
            supportsWirelessCharging: supportsWirelessCharging,
308
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
309
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
310
            notes: notes,
311
            assignTo: meterMACAddress
312
        ) ?? false
313

            
314
        if didSave {
315
            reloadChargedDevices()
316
        }
317

            
318
        return didSave
319
    }
320

            
321
    @discardableResult
Bogdan Timofte authored a month ago
322
    func createCharger(
323
        name: String,
324
        notes: String?,
325
        meterMACAddress: String?
326
    ) -> Bool {
327
        let didSave = chargeInsightsStore?.createCharger(
328
            name: name,
329
            notes: notes,
330
            assignTo: meterMACAddress
331
        ) ?? false
332

            
333
        if didSave {
334
            reloadChargedDevices()
335
        }
336

            
337
        return didSave
338
    }
339

            
340
    @discardableResult
341
    func updateDevice(
Bogdan Timofte authored a month ago
342
        id: UUID,
343
        name: String,
344
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
345
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
346
        supportsWiredCharging: Bool,
347
        supportsWirelessCharging: Bool,
348
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
349
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
350
        notes: String?
351
    ) -> Bool {
Bogdan Timofte authored a month ago
352
        let didSave = chargeInsightsStore?.updateDevice(
Bogdan Timofte authored a month ago
353
            id: id,
354
            name: name,
355
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
356
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
357
            supportsWiredCharging: supportsWiredCharging,
358
            supportsWirelessCharging: supportsWirelessCharging,
359
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
360
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
361
            notes: notes
362
        ) ?? false
363

            
364
        if didSave {
365
            reloadChargedDevices()
366
        }
367

            
368
        return didSave
369
    }
370

            
371
    @discardableResult
Bogdan Timofte authored a month ago
372
    func updateCharger(
373
        id: UUID,
374
        name: String,
375
        notes: String?
376
    ) -> Bool {
377
        let didSave = chargeInsightsStore?.updateCharger(
378
            id: id,
379
            name: name,
380
            notes: notes
Bogdan Timofte authored a month ago
381
        ) ?? false
382

            
383
        if didSave {
384
            reloadChargedDevices()
385
        }
386

            
387
        return didSave
388
    }
389

            
390
    @discardableResult
391
    func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool {
392
        let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false
393
        if didSave {
394
            reloadChargedDevices()
395
        }
396
        return didSave
397
    }
398

            
399
    @discardableResult
400
    func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
401
        let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false
402
        if didSave {
403
            reloadChargedDevices()
404
        }
405
        return didSave
406
    }
407

            
Bogdan Timofte authored a month ago
408
    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
409
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
410
            return
411
        }
412
        guard activeSession.status == .active else {
413
            return
414
        }
415
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
416
    }
417

            
Bogdan Timofte authored a month ago
418
    @discardableResult
Bogdan Timofte authored a month ago
419
    func startChargeSession(
420
        for meter: Meter,
421
        chargedDeviceID: UUID,
422
        chargerID: UUID?,
423
        chargingTransportMode: ChargingTransportMode,
424
        chargingStateMode: ChargingStateMode,
425
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
426
        initialBatteryPercent: Double?,
427
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
428
    ) -> Bool {
Bogdan Timofte authored a month ago
429
        meter.resetMeterCountersForNewSession()
430

            
Bogdan Timofte authored a month ago
431
        guard let snapshot = meter.chargingMonitorSnapshot else {
432
            return false
433
        }
434

            
Bogdan Timofte authored a month ago
435
        let didSave = chargeInsightsStore?.startSession(
436
            for: snapshot,
437
            chargedDeviceID: chargedDeviceID,
438
            chargerID: chargerID,
439
            chargingTransportMode: chargingTransportMode,
440
            chargingStateMode: chargingStateMode,
441
            autoStopEnabled: autoStopEnabled,
Bogdan Timofte authored a month ago
442
            initialBatteryPercent: initialBatteryPercent,
443
            startsFromFlatBattery: startsFromFlatBattery
Bogdan Timofte authored a month ago
444
        ) ?? false
445
        if didSave {
446
            reloadChargedDevices()
Bogdan Timofte authored a month ago
447
            meter.resetChargeRecordGraph()
448
            if let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description),
449
               meter.supportsRecordingThreshold,
450
               activeSession.stopThresholdAmps > 0 {
451
                meter.recordingTreshold = activeSession.stopThresholdAmps
452
            }
453
            restoreChargeMonitoringStateIfNeeded(for: meter)
Bogdan Timofte authored a month ago
454
        }
455
        return didSave
456
    }
457

            
458
    @discardableResult
459
    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
460
        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
461
        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
462
        if didSave {
463
            reloadChargedDevices()
464
        }
465
        return didSave
466
    }
467

            
468
    @discardableResult
469
    func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
470
        let snapshot = meter?.chargingMonitorSnapshot
471
        let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
472
        if didSave {
473
            reloadChargedDevices()
474
        }
475
        return didSave
476
    }
477

            
478
    @discardableResult
479
    func stopChargeSession(
480
        sessionID: UUID,
481
        finalBatteryPercent: Double,
482
        label: String? = "Final"
483
    ) -> Bool {
484
        let didSave = chargeInsightsStore?.stopSession(
485
            id: sessionID,
486
            finalBatteryPercent: finalBatteryPercent,
487
            label: label
488
        ) ?? false
Bogdan Timofte authored a month ago
489
        if didSave {
490
            reloadChargedDevices()
491
        }
492
        return didSave
493
    }
494

            
495
    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
496
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
497
            return
498
        }
499

            
500
        if chargeInsightsStore?.observe(snapshot: snapshot) == true {
501
            reloadChargedDevices()
502
        }
503
    }
504

            
505
    @discardableResult
506
    func addBatteryCheckpoint(percent: Double, label: String?, for meter: Meter) -> Bool {
Bogdan Timofte authored a month ago
507
        observeChargeSnapshot(from: meter)
508

            
509
        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
510
        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
511
        let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
512

            
Bogdan Timofte authored a month ago
513
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
514
            percent: percent,
515
            label: label,
Bogdan Timofte authored a month ago
516
            for: meter.btSerial.macAddress.description,
517
            measuredEnergyWh: checkpointEnergyWh,
518
            measuredChargeAh: checkpointChargeAh
Bogdan Timofte authored a month ago
519
        ) ?? false
520

            
521
        if didSave {
522
            reloadChargedDevices()
523
        }
524

            
525
        return didSave
526
    }
527

            
528
    @discardableResult
529
    func addBatteryCheckpoint(percent: Double, label: String?, for sessionID: UUID) -> Bool {
530
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
531
            percent: percent,
532
            label: label,
533
            for: sessionID
534
        ) ?? false
535

            
536
        if didSave {
537
            reloadChargedDevices()
538
        }
539

            
540
        return didSave
541
    }
542

            
Bogdan Timofte authored a month ago
543
    func batteryCheckpointPlausibilityWarning(
544
        percent: Double,
545
        for sessionID: UUID
546
    ) -> BatteryCheckpointPlausibilityWarning? {
547
        guard let session = chargeSessionSummary(id: sessionID) else {
548
            return nil
549
        }
550
        return batteryCheckpointPlausibilityWarning(percent: percent, for: session)
551
    }
552

            
553
    @discardableResult
554
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
555
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
556
            id: checkpointID,
557
            from: sessionID
558
        ) ?? false
559

            
560
        if didDelete {
561
            reloadChargedDevices()
562
        }
563

            
564
        return didDelete
565
    }
566

            
Bogdan Timofte authored a month ago
567
    @discardableResult
568
    func flushChargeInsights() -> Bool {
569
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
570
        reloadChargedDevices()
571
        return didSave
572
    }
573

            
574
    @discardableResult
575
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
576
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
577
            return false
578
        }
579
        return setTargetBatteryPercent(percent, for: activeSession.id)
580
    }
581

            
582
    @discardableResult
583
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
584
        if percent != nil {
585
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
586
        }
587

            
588
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
589
        if didSave {
590
            reloadChargedDevices()
591
        }
592
        return didSave
593
    }
594

            
595
    @discardableResult
596
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
597
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
598
        if didSave {
599
            reloadChargedDevices()
600
        }
601
        return didSave
602
    }
603

            
604
    @discardableResult
605
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
606
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
607
        if didSave {
608
            reloadChargedDevices()
609
        }
610
        return didSave
611
    }
612

            
613
    @discardableResult
614
    func deleteChargeSession(sessionID: UUID) -> Bool {
615
        let deletedSession = chargedDevices
616
            .flatMap(\.sessions)
617
            .first(where: { $0.id == sessionID })
618

            
619
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
620
        guard didDelete else {
621
            return false
622
        }
623

            
Bogdan Timofte authored a month ago
624
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
625
           let meterMACAddress = deletedSession?.meterMACAddress,
626
           let liveMeter = meter(for: meterMACAddress) {
627
            liveMeter.resetChargeRecord()
628
        }
629

            
630
        reloadChargedDevices()
631
        return true
632
    }
633

            
634
    @discardableResult
635
    func deleteChargedDevice(id: UUID) -> Bool {
636
        let deletedDevice = chargedDeviceSummary(id: id)
637
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
638
        guard didDelete else {
639
            return false
640
        }
641

            
Bogdan Timofte authored a month ago
642
        if deletedDevice?.isCharger == true {
643
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
644
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
645
                session.stop()
646
                activeChargerStandbySessions[meterMACAddress] = nil
647
            }
648
        }
649

            
Bogdan Timofte authored a month ago
650
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
651
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
652
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
653
           let liveMeter = meter(for: meterMACAddress) {
654
            liveMeter.resetChargeRecord()
655
        }
656

            
657
        reloadChargedDevices()
658
        return true
659
    }
660

            
661
    @discardableResult
662
    func createKnownMeter(
663
        macAddress: String,
664
        customName: String?,
665
        modelName: String,
666
        advertisedName: String?
667
    ) -> Bool {
668
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
669
        guard Self.isValidMACAddress(normalizedMAC) else {
670
            return false
671
        }
672

            
673
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
674
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
675
            setMeterName(customName, for: normalizedMAC)
676
        }
677
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
678
        return true
679
    }
680

            
681
    @discardableResult
682
    func deleteMeter(macAddress: String) -> Bool {
683
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
684
        guard Self.isValidMACAddress(normalizedMAC) else {
685
            return false
686
        }
687

            
688
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
689
            meter.disconnect()
690
        }
691
        meters = meters.filter { element in
692
            element.value.btSerial.macAddress.description != normalizedMAC
693
        }
694

            
695
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
696
        if didDelete {
697
            scheduleObjectWillChange()
698
        }
699
        return didDelete
700
    }
701

            
Bogdan Timofte authored 2 months ago
702
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored 2 months ago
703
        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
704
        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
705
        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
706

            
707
        return macAddresses.map { macAddress in
708
            let liveMeter = liveMetersByMAC[macAddress]
709
            let record = recordsByMAC[macAddress]
710

            
Bogdan Timofte authored 2 months ago
711
            return MeterSummary(
Bogdan Timofte authored 2 months ago
712
                macAddress: macAddress,
Bogdan Timofte authored 2 months ago
713
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
Bogdan Timofte authored 2 months ago
714
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
Bogdan Timofte authored 2 months ago
715
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
716
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
717
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
718
                meter: liveMeter
719
            )
720
        }
721
        .sorted { lhs, rhs in
Bogdan Timofte authored 2 months ago
722
            if lhs.meter != nil && rhs.meter == nil {
723
                return true
724
            }
725
            if lhs.meter == nil && rhs.meter != nil {
726
                return false
727
            }
Bogdan Timofte authored 2 months ago
728
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
729
            if byName != .orderedSame {
730
                return byName == .orderedAscending
731
            }
732
            return lhs.macAddress < rhs.macAddress
733
        }
734
    }
735

            
Bogdan Timofte authored 2 months ago
736
    private func scheduleObjectWillChange() {
737
        DispatchQueue.main.async { [weak self] in
738
            self?.objectWillChange.send()
739
        }
740
    }
Bogdan Timofte authored 2 months ago
741

            
Bogdan Timofte authored a month ago
742
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
743
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
744
        chargedDevices = (chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
745
            chargedDevice.withStandbyPowerMeasurements(
746
                standbyMeasurementsByChargerID[chargedDevice.id] ?? []
747
            )
748
        }
Bogdan Timofte authored a month ago
749
        chargeNotificationCoordinator.process(chargedDevices: deviceSummaries)
750
        for meter in meters.values {
751
            restoreChargeMonitoringStateIfNeeded(for: meter)
752
        }
753
    }
754

            
755
    private func meter(for meterMACAddress: String) -> Meter? {
756
        meters.values.first { meter in
757
            meter.btSerial.macAddress.description == meterMACAddress
758
        }
759
    }
760

            
Bogdan Timofte authored 2 months ago
761
    private func refreshMeterMetadata() {
762
        DispatchQueue.main.async { [weak self] in
763
            guard let self else { return }
764
            var didUpdateAnyMeter = false
765
            for meter in self.meters.values {
766
                let mac = meter.btSerial.macAddress.description
767
                let displayName = self.meterName(for: mac) ?? mac
768
                if meter.name != displayName {
769
                    meter.updateNameFromStore(displayName)
770
                    didUpdateAnyMeter = true
771
                }
772

            
773
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
774
                meter.reloadTemperatureUnitPreference()
775
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
776
                    didUpdateAnyMeter = true
777
                }
778
            }
779

            
780
            if didUpdateAnyMeter {
781
                self.scheduleObjectWillChange()
782
            }
783
        }
784
    }
Bogdan Timofte authored a month ago
785

            
Bogdan Timofte authored a month ago
786
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
787
        guard let charger = chargedDeviceSummary(id: session.chargerID),
788
              let statistics = session.statistics else {
789
            return
790
        }
791

            
792
        let content = UNMutableNotificationContent()
793
        content.title = "Standby baseline stabilised"
794
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
795
        content.sound = .default
796
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
797

            
798
        let request = UNNotificationRequest(
799
            identifier: "charger-standby-\(session.id.uuidString)",
800
            content: content,
801
            trigger: nil
802
        )
803
        UNUserNotificationCenter.current().add(request)
804
        scheduleObjectWillChange()
805
    }
806

            
Bogdan Timofte authored a month ago
807
    private func batteryCheckpointPlausibilityWarning(
808
        percent: Double,
809
        for session: ChargeSessionSummary
810
    ) -> BatteryCheckpointPlausibilityWarning? {
811
        guard percent.isFinite, percent >= 0, percent <= 100 else {
812
            return nil
813
        }
814

            
815
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
816
            if lhs.timestamp != rhs.timestamp {
817
                return lhs.timestamp < rhs.timestamp
818
            }
819
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
820
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
821
            }
822
            return lhs.id.uuidString < rhs.id.uuidString
823
        }
824

            
825
        if let lastCheckpoint = sortedCheckpoints.last,
826
           percent < lastCheckpoint.batteryPercent - 1.5 {
827
            return BatteryCheckpointPlausibilityWarning(
828
                title: "Checkpoint Goes Backwards",
829
                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."
830
            )
831
        }
832

            
833
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
834
              let prediction = chargedDevice.batteryLevelPrediction(for: session)
835
        else {
836
            return nil
837
        }
838

            
839
        let predictionGap = percent - prediction.predictedPercent
840
        guard abs(predictionGap) >= 4 else {
841
            return nil
842
        }
843

            
844
        let direction = predictionGap > 0 ? "above" : "below"
845
        let gapText = abs(predictionGap).format(decimalDigits: 0)
846
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
847

            
848
        if let lastCheckpoint = sortedCheckpoints.last {
849
            let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh
850
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
851
            return BatteryCheckpointPlausibilityWarning(
852
                title: "Checkpoint Looks Implausible",
853
                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."
854
            )
855
        }
856

            
857
        return BatteryCheckpointPlausibilityWarning(
858
            title: "Checkpoint Looks Implausible",
859
            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."
860
        )
861
    }
Bogdan Timofte authored a month ago
862

            
863
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
864
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
865
        guard session.status.isOpen else {
866
            return storedEnergyWh
867
        }
868

            
869
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
870
            return storedEnergyWh
871
        }
872

            
873
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
874
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
875
        }
876

            
877
        return storedEnergyWh
878
    }
879

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

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

            
890
        if let baselineChargeAh = session.meterChargeBaselineAh {
891
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
892
        }
893

            
894
        return storedChargeAh
895
    }
Bogdan Timofte authored 2 months ago
896
}
Bogdan Timofte authored 2 months ago
897

            
898
extension AppData.MeterSummary {
899
    var tint: Color {
900
        switch modelSummary {
901
        case "UM25C":
902
            return .blue
903
        case "UM34C":
904
            return .yellow
905
        case "TC66C":
906
            return Model.TC66C.color
907
        default:
908
            return .secondary
909
        }
910
    }
911
}
Bogdan Timofte authored 2 months ago
912

            
Bogdan Timofte authored a month ago
913
extension AppData {
Bogdan Timofte authored 2 months ago
914
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
915
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
916
            return liveName
917
        }
918
        if let customName = record?.customName {
919
            return customName
920
        }
921
        if let advertisedName = record?.advertisedName {
922
            return advertisedName
923
        }
924
        if let recordModel = record?.modelName {
925
            return recordModel
926
        }
927
        if let liveModel = liveMeter?.deviceModelSummary {
928
            return liveModel
929
        }
930
        return "Meter"
931
    }
Bogdan Timofte authored a month ago
932

            
933
    static func normalizedMACAddress(_ macAddress: String) -> String {
934
        macAddress
935
            .trimmingCharacters(in: .whitespacesAndNewlines)
936
            .uppercased()
937
    }
938

            
939
    static func isValidMACAddress(_ macAddress: String) -> Bool {
940
        macAddress.range(
941
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
942
            options: .regularExpression
943
        ) != nil
944
    }
945
}
946

            
947
private final class ChargeNotificationCoordinator {
948
    private struct Payload {
949
        let id: String
950
        let title: String
951
        let body: String
952
        let threadIdentifier: String
953
    }
954

            
955
    private let notificationCenter = UNUserNotificationCenter.current()
956
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
957
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
958
    private var inFlightEventIDs: Set<String> = []
959

            
960
    func ensureAuthorizationIfNeeded() {
961
        notificationCenter.getNotificationSettings { [weak self] settings in
962
            guard settings.authorizationStatus == .notDetermined else {
963
                return
964
            }
965

            
966
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
967
                if let error {
968
                    track("Notification authorization request failed: \(error.localizedDescription)")
969
                }
970
            }
971
        }
972
    }
973

            
974
    func process(chargedDevices: [ChargedDeviceSummary]) {
975
        let now = Date()
976
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
977
            payloads(for: chargedDevice, now: now)
978
        }
979

            
980
        for payload in pendingPayloads {
981
            scheduleIfNeeded(payload)
982
        }
983
    }
984

            
985
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
986
        chargedDevice.sessions.compactMap { session in
987
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
988
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
989
               let targetBatteryPercent = session.targetBatteryPercent {
990
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
991
                    ?? session.endBatteryPercent
992
                    ?? targetBatteryPercent
993

            
994
                return Payload(
995
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
996
                    title: "Battery target reached",
997
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
998
                    threadIdentifier: session.id.uuidString
999
                )
1000
            }
1001

            
1002
            if session.requiresCompletionConfirmation,
1003
               let requestedAt = session.completionConfirmationRequestedAt,
1004
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
1005
                let estimatedPercent = session.completionContradictionPercent
1006
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1007
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
1008
                let detail = estimatedPercent.map {
1009
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
1010
                } ?? ""
1011

            
1012
                return Payload(
1013
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1014
                    title: "Confirm charge completion",
1015
                    body: bodyPrefix + detail,
1016
                    threadIdentifier: session.id.uuidString
1017
                )
1018
            }
1019

            
1020
            return nil
1021
        }
1022
    }
1023

            
1024
    private func scheduleIfNeeded(_ payload: Payload) {
1025
        guard deliveredEventIDs().contains(payload.id) == false else {
1026
            return
1027
        }
1028

            
1029
        guard inFlightEventIDs.contains(payload.id) == false else {
1030
            return
1031
        }
1032

            
1033
        inFlightEventIDs.insert(payload.id)
1034

            
1035
        notificationCenter.getNotificationSettings { [weak self] settings in
1036
            guard let self else { return }
1037
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1038
                DispatchQueue.main.async {
1039
                    self.inFlightEventIDs.remove(payload.id)
1040
                }
1041
                return
1042
            }
1043

            
1044
            let content = UNMutableNotificationContent()
1045
            content.title = payload.title
1046
            content.body = payload.body
1047
            content.sound = .default
1048
            content.threadIdentifier = payload.threadIdentifier
1049

            
1050
            let request = UNNotificationRequest(
1051
                identifier: payload.id,
1052
                content: content,
1053
                trigger: nil
1054
            )
1055

            
1056
            self.notificationCenter.add(request) { error in
1057
                DispatchQueue.main.async {
1058
                    self.inFlightEventIDs.remove(payload.id)
1059
                    if let error {
1060
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1061
                        return
1062
                    }
1063
                    self.storeDeliveredEventID(payload.id)
1064
                }
1065
            }
1066
        }
1067
    }
1068

            
1069
    private func deliveredEventIDs() -> Set<String> {
1070
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1071
        return Set(values)
1072
    }
1073

            
1074
    private func storeDeliveredEventID(_ id: String) {
1075
        var values = deliveredEventIDs()
1076
        values.insert(id)
1077
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1078
    }
Bogdan Timofte authored 2 months ago
1079
}