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

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

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

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

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

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

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

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

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

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

            
101
        chargeInsightsStoreObserver = NotificationCenter.default.publisher(
102
            for: .NSManagedObjectContextObjectsDidChange,
103
            object: context
104
        )
105
        .receive(on: DispatchQueue.main)
106
        .sink { [weak self] _ in
Bogdan Timofte authored a month ago
107
            self?.scheduleChargedDevicesReload()
Bogdan Timofte authored a month ago
108
        }
109

            
110
        chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
111
            for: .NSPersistentStoreRemoteChange,
112
            object: nil
113
        )
114
        .receive(on: DispatchQueue.main)
115
        .sink { [weak self] _ in
Bogdan Timofte authored a month ago
116
            self?.scheduleChargedDevicesReload()
Bogdan Timofte authored a month ago
117
        }
118

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
198
        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
Bogdan Timofte authored a month ago
199
           let liveDevice = chargedDevices.first(where: {
200
               $0.id == activeSession.chargedDeviceID && $0.isCharger == false
201
           }) {
202
            return liveDevice
203
        }
204

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

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

            
Bogdan Timofte authored a month ago
213
        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
Bogdan Timofte authored a month ago
214
           let chargerID = activeSession.chargerID,
215
           let liveCharger = chargedDevices.first(where: {
216
               $0.id == chargerID && $0.isCharger
217
           }) {
218
            return liveCharger
219
        }
220

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

            
226
    func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
Bogdan Timofte authored a month ago
227
        if let cachedSummary = cachedActiveChargeSessionSummary(for: meterMACAddress) {
228
            return cachedSummary
229
        }
230
        return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
Bogdan Timofte authored a month ago
231
    }
232

            
Bogdan Timofte authored a month ago
233
    func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
234
        activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)]
235
    }
236

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

            
243
        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
244
        if let existingSession = activeChargerStandbySessions[normalizedMAC] {
245
            return existingSession.chargerID == chargerID
246
        }
247

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

            
257
        activeChargerStandbySessions[normalizedMAC] = session
258
        session.start()
259

            
260
        // Starting a standby run on an available meter should also initiate the BLE link.
261
        if meter.operationalState == .peripheralNotConnected {
262
            meter.connect()
263
        }
264

            
265
        scheduleObjectWillChange()
266
        return true
267
    }
268

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

            
276
        session.stop()
277

            
278
        guard save else {
279
            activeChargerStandbySessions[normalizedMAC] = nil
280
            scheduleObjectWillChange()
281
            return true
282
        }
283

            
284
        guard let summary = session.makeSummary() else {
285
            scheduleObjectWillChange()
286
            return false
287
        }
288

            
289
        let didSave = chargerStandbyPowerStore.save(summary)
290
        if didSave {
291
            activeChargerStandbySessions[normalizedMAC] = nil
292
            reloadChargedDevices()
293
        } else {
294
            scheduleObjectWillChange()
295
        }
296

            
297
        return didSave
298
    }
299

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

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

            
335
        if didSave {
336
            reloadChargedDevices()
337
        }
338

            
339
        return didSave
340
    }
341

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

            
354
        if didSave {
355
            reloadChargedDevices()
356
        }
357

            
358
        return didSave
359
    }
360

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

            
385
        if didSave {
386
            reloadChargedDevices()
387
        }
388

            
389
        return didSave
390
    }
391

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

            
404
        if didSave {
405
            reloadChargedDevices()
406
        }
407

            
408
        return didSave
409
    }
410

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

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

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

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

            
Bogdan Timofte authored a month ago
452
        guard let snapshot = meter.chargingMonitorSnapshot else {
453
            return false
454
        }
455

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

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

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

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

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

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

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

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

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

            
536
        if didSave {
537
            reloadChargedDevices()
538
        }
539

            
540
        return didSave
541
    }
542

            
543
    @discardableResult
Bogdan Timofte authored a month ago
544
    func addBatteryCheckpoint(percent: Double, for sessionID: UUID) -> Bool {
545
        guard canAddBatteryCheckpoint(to: sessionID) else {
546
            return false
547
        }
548

            
Bogdan Timofte authored a month ago
549
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
550
            percent: percent,
551
            for: sessionID
552
        ) ?? false
553

            
554
        if didSave {
555
            reloadChargedDevices()
556
        }
557

            
558
        return didSave
559
    }
560

            
Bogdan Timofte authored a month ago
561
    @discardableResult
562
    func addBatteryCheckpoint(
563
        percent: Double,
564
        for sessionID: UUID,
565
        measuredEnergyWh: Double?,
566
        measuredChargeAh: Double?
567
    ) -> Bool {
Bogdan Timofte authored a month ago
568
        guard canAddBatteryCheckpoint(to: sessionID) else {
569
            return false
570
        }
571

            
Bogdan Timofte authored a month ago
572
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
573
            percent: percent,
574
            for: sessionID,
575
            measuredEnergyWh: measuredEnergyWh,
576
            measuredChargeAh: measuredChargeAh
577
        ) ?? false
578

            
579
        if didSave {
580
            reloadChargedDevices()
581
        }
582

            
583
        return didSave
584
    }
585

            
Bogdan Timofte authored a month ago
586
    func canAddBatteryCheckpoint(to sessionID: UUID) -> Bool {
587
        guard let session = chargeSessionSummary(id: sessionID),
588
              session.status.isOpen,
589
              let meterMACAddress = session.meterMACAddress else {
590
            return false
591
        }
592

            
593
        return meter(for: meterMACAddress) != nil
594
    }
595

            
596
    func batteryCheckpointCaptureRequirementMessage(for sessionID: UUID) -> String? {
597
        guard let session = chargeSessionSummary(id: sessionID) else {
598
            return "Battery checkpoints are available only while the charge session is still active."
599
        }
600

            
601
        guard session.status.isOpen else {
602
            return "Battery checkpoints are available only while the charge session is still active."
603
        }
604

            
605
        guard let meterMACAddress = session.meterMACAddress,
606
              meter(for: meterMACAddress) != nil else {
607
            return "Add battery checkpoints only on the device that is actively monitoring this charging session. Devices following the session through iCloud may not have data that is fresh or precise enough."
608
        }
609

            
610
        return nil
611
    }
612

            
Bogdan Timofte authored a month ago
613
    func batteryCheckpointPlausibilityWarning(
614
        percent: Double,
Bogdan Timofte authored a month ago
615
        for sessionID: UUID,
616
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
617
    ) -> BatteryCheckpointPlausibilityWarning? {
618
        guard let session = chargeSessionSummary(id: sessionID) else {
619
            return nil
620
        }
Bogdan Timofte authored a month ago
621
        return batteryCheckpointPlausibilityWarning(
622
            percent: percent,
623
            for: session,
624
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
625
        )
Bogdan Timofte authored a month ago
626
    }
627

            
628
    @discardableResult
629
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
630
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
631
            id: checkpointID,
632
            from: sessionID
633
        ) ?? false
634

            
635
        if didDelete {
Bogdan Timofte authored a month ago
636
            scheduleChargedDevicesReload(delay: 0)
Bogdan Timofte authored a month ago
637
        }
638

            
639
        return didDelete
640
    }
641

            
Bogdan Timofte authored a month ago
642
    @discardableResult
643
    func flushChargeInsights() -> Bool {
644
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
645
        reloadChargedDevices()
646
        return didSave
647
    }
648

            
649
    @discardableResult
650
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
651
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
652
            return false
653
        }
654
        return setTargetBatteryPercent(percent, for: activeSession.id)
655
    }
656

            
657
    @discardableResult
658
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
659
        if percent != nil {
660
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
661
        }
662

            
663
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
664
        if didSave {
665
            reloadChargedDevices()
666
        }
667
        return didSave
668
    }
669

            
670
    @discardableResult
671
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
672
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
673
        if didSave {
674
            reloadChargedDevices()
675
        }
676
        return didSave
677
    }
678

            
679
    @discardableResult
680
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
681
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
682
        if didSave {
683
            reloadChargedDevices()
684
        }
685
        return didSave
686
    }
687

            
688
    @discardableResult
689
    func deleteChargeSession(sessionID: UUID) -> Bool {
690
        let deletedSession = chargedDevices
691
            .flatMap(\.sessions)
692
            .first(where: { $0.id == sessionID })
693

            
694
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
695
        guard didDelete else {
696
            return false
697
        }
698

            
Bogdan Timofte authored a month ago
699
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
700
           let meterMACAddress = deletedSession?.meterMACAddress,
701
           let liveMeter = meter(for: meterMACAddress) {
702
            liveMeter.resetChargeRecord()
703
        }
704

            
705
        reloadChargedDevices()
706
        return true
707
    }
708

            
709
    @discardableResult
710
    func deleteChargedDevice(id: UUID) -> Bool {
711
        let deletedDevice = chargedDeviceSummary(id: id)
712
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
713
        guard didDelete else {
714
            return false
715
        }
716

            
Bogdan Timofte authored a month ago
717
        if deletedDevice?.isCharger == true {
718
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
719
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
720
                session.stop()
721
                activeChargerStandbySessions[meterMACAddress] = nil
722
            }
723
        }
724

            
Bogdan Timofte authored a month ago
725
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
726
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
727
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
728
           let liveMeter = meter(for: meterMACAddress) {
729
            liveMeter.resetChargeRecord()
730
        }
731

            
732
        reloadChargedDevices()
733
        return true
734
    }
735

            
736
    @discardableResult
737
    func createKnownMeter(
738
        macAddress: String,
739
        customName: String?,
740
        modelName: String,
741
        advertisedName: String?
742
    ) -> Bool {
743
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
744
        guard Self.isValidMACAddress(normalizedMAC) else {
745
            return false
746
        }
747

            
748
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
749
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
750
            setMeterName(customName, for: normalizedMAC)
751
        }
752
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
753
        return true
754
    }
755

            
756
    @discardableResult
757
    func deleteMeter(macAddress: String) -> Bool {
758
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
759
        guard Self.isValidMACAddress(normalizedMAC) else {
760
            return false
761
        }
762

            
763
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
764
            meter.disconnect()
765
        }
766
        meters = meters.filter { element in
767
            element.value.btSerial.macAddress.description != normalizedMAC
768
        }
769

            
770
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
771
        if didDelete {
772
            scheduleObjectWillChange()
773
        }
774
        return didDelete
775
    }
776

            
Bogdan Timofte authored 2 months ago
777
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored 2 months ago
778
        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
779
        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
780
        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
781

            
782
        return macAddresses.map { macAddress in
783
            let liveMeter = liveMetersByMAC[macAddress]
784
            let record = recordsByMAC[macAddress]
785

            
Bogdan Timofte authored 2 months ago
786
            return MeterSummary(
Bogdan Timofte authored 2 months ago
787
                macAddress: macAddress,
Bogdan Timofte authored 2 months ago
788
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
Bogdan Timofte authored 2 months ago
789
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
Bogdan Timofte authored 2 months ago
790
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
791
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
792
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
793
                meter: liveMeter
794
            )
795
        }
796
        .sorted { lhs, rhs in
Bogdan Timofte authored 2 months ago
797
            if lhs.meter != nil && rhs.meter == nil {
798
                return true
799
            }
800
            if lhs.meter == nil && rhs.meter != nil {
801
                return false
802
            }
Bogdan Timofte authored 2 months ago
803
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
804
            if byName != .orderedSame {
805
                return byName == .orderedAscending
806
            }
807
            return lhs.macAddress < rhs.macAddress
808
        }
809
    }
810

            
Bogdan Timofte authored 2 months ago
811
    private func scheduleObjectWillChange() {
812
        DispatchQueue.main.async { [weak self] in
813
            self?.objectWillChange.send()
814
        }
815
    }
Bogdan Timofte authored 2 months ago
816

            
Bogdan Timofte authored a month ago
817
    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
818
        pendingChargedDevicesReloadWorkItem?.cancel()
819

            
820
        let workItem = DispatchWorkItem { [weak self] in
821
            self?.reloadChargedDevices()
822
        }
823
        pendingChargedDevicesReloadWorkItem = workItem
824
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
825
    }
826

            
827
    private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
828
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
829
        guard !normalizedMAC.isEmpty else {
830
            return nil
831
        }
832

            
833
        return chargedDevices
834
            .lazy
835
            .compactMap(\.activeSession)
836
            .first(where: { $0.status == .active && $0.meterMACAddress == normalizedMAC })
837
    }
838

            
Bogdan Timofte authored a month ago
839
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
840
        pendingChargedDevicesReloadWorkItem?.cancel()
841
        pendingChargedDevicesReloadWorkItem = nil
842

            
Bogdan Timofte authored a month ago
843
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
844
        chargedDevices = (chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
845
            chargedDevice.withStandbyPowerMeasurements(
846
                standbyMeasurementsByChargerID[chargedDevice.id] ?? []
847
            )
848
        }
Bogdan Timofte authored a month ago
849
        chargeNotificationCoordinator.process(chargedDevices: deviceSummaries)
850
        for meter in meters.values {
851
            restoreChargeMonitoringStateIfNeeded(for: meter)
852
        }
853
    }
854

            
855
    private func meter(for meterMACAddress: String) -> Meter? {
856
        meters.values.first { meter in
857
            meter.btSerial.macAddress.description == meterMACAddress
858
        }
859
    }
860

            
Bogdan Timofte authored 2 months ago
861
    private func refreshMeterMetadata() {
862
        DispatchQueue.main.async { [weak self] in
863
            guard let self else { return }
864
            var didUpdateAnyMeter = false
865
            for meter in self.meters.values {
866
                let mac = meter.btSerial.macAddress.description
867
                let displayName = self.meterName(for: mac) ?? mac
868
                if meter.name != displayName {
869
                    meter.updateNameFromStore(displayName)
870
                    didUpdateAnyMeter = true
871
                }
872

            
873
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
874
                meter.reloadTemperatureUnitPreference()
875
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
876
                    didUpdateAnyMeter = true
877
                }
878
            }
879

            
880
            if didUpdateAnyMeter {
881
                self.scheduleObjectWillChange()
882
            }
883
        }
884
    }
Bogdan Timofte authored a month ago
885

            
Bogdan Timofte authored a month ago
886
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
887
        guard let charger = chargedDeviceSummary(id: session.chargerID),
888
              let statistics = session.statistics else {
889
            return
890
        }
891

            
892
        let content = UNMutableNotificationContent()
893
        content.title = "Standby baseline stabilised"
894
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
895
        content.sound = .default
896
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
897

            
898
        let request = UNNotificationRequest(
899
            identifier: "charger-standby-\(session.id.uuidString)",
900
            content: content,
901
            trigger: nil
902
        )
903
        UNUserNotificationCenter.current().add(request)
904
        scheduleObjectWillChange()
905
    }
906

            
Bogdan Timofte authored a month ago
907
    private func batteryCheckpointPlausibilityWarning(
908
        percent: Double,
Bogdan Timofte authored a month ago
909
        for session: ChargeSessionSummary,
910
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
911
    ) -> BatteryCheckpointPlausibilityWarning? {
912
        guard percent.isFinite, percent >= 0, percent <= 100 else {
913
            return nil
914
        }
915

            
916
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
917
            if lhs.timestamp != rhs.timestamp {
918
                return lhs.timestamp < rhs.timestamp
919
            }
920
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
921
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
922
            }
923
            return lhs.id.uuidString < rhs.id.uuidString
924
        }
925

            
926
        if let lastCheckpoint = sortedCheckpoints.last,
927
           percent < lastCheckpoint.batteryPercent - 1.5 {
928
            return BatteryCheckpointPlausibilityWarning(
929
                title: "Checkpoint Goes Backwards",
930
                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."
931
            )
932
        }
933

            
Bogdan Timofte authored a month ago
934
        let effectiveEnergyWh = effectiveEnergyWhOverride
935
            ?? session.effectiveBatteryEnergyWh
936
            ?? session.measuredEnergyWh
937

            
938
        if let lastCheckpoint = sortedCheckpoints.last,
939
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
940
            let estimatedCapacityWh = session.capacityEstimateWh
941
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
942
                ?? chargedDevice.estimatedBatteryCapacityWh
943

            
944
            if let estimatedCapacityWh, estimatedCapacityWh > 0 {
945
                let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
946
                let expectedPercent = min(
947
                    100,
948
                    max(
949
                        lastCheckpoint.batteryPercent,
950
                        lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
951
                    )
952
                )
953
                let predictionGap = percent - expectedPercent
954
                guard abs(predictionGap) >= 4 else {
955
                    return nil
956
                }
957

            
958
                let direction = predictionGap > 0 ? "above" : "below"
959
                let gapText = abs(predictionGap).format(decimalDigits: 0)
960
                let expectedText = expectedPercent.format(decimalDigits: 0)
961

            
962
                return BatteryCheckpointPlausibilityWarning(
963
                    title: "Checkpoint Looks Implausible",
964
                    message: "The last checkpoint stored \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh. The current counted energy is \(effectiveEnergyWh.format(decimalDigits: 2)) Wh, which supports about \(expectedText)% based on \(estimatedCapacityWh.format(decimalDigits: 2)) Wh capacity. The entered value is about \(gapText) percentage points \(direction) that."
965
                )
966
            }
967
        }
968

            
Bogdan Timofte authored a month ago
969
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
Bogdan Timofte authored a month ago
970
              let prediction = chargedDevice.batteryLevelPrediction(
971
                for: session,
972
                effectiveEnergyWhOverride: effectiveEnergyWh
973
              )
Bogdan Timofte authored a month ago
974
        else {
975
            return nil
976
        }
977

            
978
        let predictionGap = percent - prediction.predictedPercent
979
        guard abs(predictionGap) >= 4 else {
980
            return nil
981
        }
982

            
983
        let direction = predictionGap > 0 ? "above" : "below"
984
        let gapText = abs(predictionGap).format(decimalDigits: 0)
985
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
986

            
987
        if let lastCheckpoint = sortedCheckpoints.last {
988
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
989
            return BatteryCheckpointPlausibilityWarning(
990
                title: "Checkpoint Looks Implausible",
991
                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."
992
            )
993
        }
994

            
995
        return BatteryCheckpointPlausibilityWarning(
996
            title: "Checkpoint Looks Implausible",
997
            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."
998
        )
999
    }
Bogdan Timofte authored a month ago
1000

            
1001
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1002
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1003
        guard session.status.isOpen else {
1004
            return storedEnergyWh
1005
        }
1006

            
1007
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1008
            return storedEnergyWh
1009
        }
1010

            
1011
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1012
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
1013
        }
1014

            
1015
        return storedEnergyWh
1016
    }
1017

            
1018
    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1019
        let storedChargeAh = session.measuredChargeAh
1020
        guard session.status.isOpen else {
1021
            return storedChargeAh
1022
        }
1023

            
1024
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1025
            return storedChargeAh
1026
        }
1027

            
1028
        if let baselineChargeAh = session.meterChargeBaselineAh {
1029
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
1030
        }
1031

            
1032
        return storedChargeAh
1033
    }
Bogdan Timofte authored 2 months ago
1034
}
Bogdan Timofte authored 2 months ago
1035

            
1036
extension AppData.MeterSummary {
1037
    var tint: Color {
1038
        switch modelSummary {
1039
        case "UM25C":
1040
            return .blue
1041
        case "UM34C":
1042
            return .yellow
1043
        case "TC66C":
1044
            return Model.TC66C.color
1045
        default:
1046
            return .secondary
1047
        }
1048
    }
1049
}
Bogdan Timofte authored 2 months ago
1050

            
Bogdan Timofte authored a month ago
1051
extension AppData {
Bogdan Timofte authored 2 months ago
1052
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
1053
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
1054
            return liveName
1055
        }
1056
        if let customName = record?.customName {
1057
            return customName
1058
        }
1059
        if let advertisedName = record?.advertisedName {
1060
            return advertisedName
1061
        }
1062
        if let recordModel = record?.modelName {
1063
            return recordModel
1064
        }
1065
        if let liveModel = liveMeter?.deviceModelSummary {
1066
            return liveModel
1067
        }
1068
        return "Meter"
1069
    }
Bogdan Timofte authored a month ago
1070

            
1071
    static func normalizedMACAddress(_ macAddress: String) -> String {
1072
        macAddress
1073
            .trimmingCharacters(in: .whitespacesAndNewlines)
1074
            .uppercased()
1075
    }
1076

            
1077
    static func isValidMACAddress(_ macAddress: String) -> Bool {
1078
        macAddress.range(
1079
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
1080
            options: .regularExpression
1081
        ) != nil
1082
    }
1083
}
1084

            
1085
private final class ChargeNotificationCoordinator {
1086
    private struct Payload {
1087
        let id: String
1088
        let title: String
1089
        let body: String
1090
        let threadIdentifier: String
1091
    }
1092

            
1093
    private let notificationCenter = UNUserNotificationCenter.current()
1094
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
1095
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
1096
    private var inFlightEventIDs: Set<String> = []
1097

            
1098
    func ensureAuthorizationIfNeeded() {
1099
        notificationCenter.getNotificationSettings { [weak self] settings in
1100
            guard settings.authorizationStatus == .notDetermined else {
1101
                return
1102
            }
1103

            
1104
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
1105
                if let error {
1106
                    track("Notification authorization request failed: \(error.localizedDescription)")
1107
                }
1108
            }
1109
        }
1110
    }
1111

            
1112
    func process(chargedDevices: [ChargedDeviceSummary]) {
1113
        let now = Date()
1114
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
1115
            payloads(for: chargedDevice, now: now)
1116
        }
1117

            
1118
        for payload in pendingPayloads {
1119
            scheduleIfNeeded(payload)
1120
        }
1121
    }
1122

            
1123
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1124
        chargedDevice.sessions.compactMap { session in
1125
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1126
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1127
               let targetBatteryPercent = session.targetBatteryPercent {
1128
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1129
                    ?? session.endBatteryPercent
1130
                    ?? targetBatteryPercent
1131

            
1132
                return Payload(
1133
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1134
                    title: "Battery target reached",
1135
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1136
                    threadIdentifier: session.id.uuidString
1137
                )
1138
            }
1139

            
1140
            if session.requiresCompletionConfirmation,
1141
               let requestedAt = session.completionConfirmationRequestedAt,
1142
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
1143
                let estimatedPercent = session.completionContradictionPercent
1144
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1145
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
1146
                let detail = estimatedPercent.map {
1147
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
1148
                } ?? ""
1149

            
1150
                return Payload(
1151
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1152
                    title: "Confirm charge completion",
1153
                    body: bodyPrefix + detail,
1154
                    threadIdentifier: session.id.uuidString
1155
                )
1156
            }
1157

            
1158
            return nil
1159
        }
1160
    }
1161

            
1162
    private func scheduleIfNeeded(_ payload: Payload) {
1163
        guard deliveredEventIDs().contains(payload.id) == false else {
1164
            return
1165
        }
1166

            
1167
        guard inFlightEventIDs.contains(payload.id) == false else {
1168
            return
1169
        }
1170

            
1171
        inFlightEventIDs.insert(payload.id)
1172

            
1173
        notificationCenter.getNotificationSettings { [weak self] settings in
1174
            guard let self else { return }
1175
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1176
                DispatchQueue.main.async {
1177
                    self.inFlightEventIDs.remove(payload.id)
1178
                }
1179
                return
1180
            }
1181

            
1182
            let content = UNMutableNotificationContent()
1183
            content.title = payload.title
1184
            content.body = payload.body
1185
            content.sound = .default
1186
            content.threadIdentifier = payload.threadIdentifier
1187

            
1188
            let request = UNNotificationRequest(
1189
                identifier: payload.id,
1190
                content: content,
1191
                trigger: nil
1192
            )
1193

            
1194
            self.notificationCenter.add(request) { error in
1195
                DispatchQueue.main.async {
1196
                    self.inFlightEventIDs.remove(payload.id)
1197
                    if let error {
1198
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1199
                        return
1200
                    }
1201
                    self.storeDeliveredEventID(payload.id)
1202
                }
1203
            }
1204
        }
1205
    }
1206

            
1207
    private func deliveredEventIDs() -> Set<String> {
1208
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1209
        return Set(values)
1210
    }
1211

            
1212
    private func storeDeliveredEventID(_ id: String) {
1213
        var values = deliveredEventIDs()
1214
        values.insert(id)
1215
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1216
    }
Bogdan Timofte authored 2 months ago
1217
}