Newer Older
1223 lines | 44.579kb
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
        templateID: String?,
Bogdan Timofte authored a month ago
316
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
317
        supportsWiredCharging: Bool,
318
        supportsWirelessCharging: Bool,
319
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
320
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
321
        notes: String?,
322
        meterMACAddress: String?
323
    ) -> Bool {
Bogdan Timofte authored a month ago
324
        let didSave = chargeInsightsStore?.createDevice(
Bogdan Timofte authored a month ago
325
            name: name,
326
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
327
            templateID: templateID,
Bogdan Timofte authored a month ago
328
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
329
            supportsWiredCharging: supportsWiredCharging,
330
            supportsWirelessCharging: supportsWirelessCharging,
331
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
332
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
333
            notes: notes,
334
            assignTo: meterMACAddress
335
        ) ?? false
336

            
337
        if didSave {
338
            reloadChargedDevices()
339
        }
340

            
341
        return didSave
342
    }
343

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

            
358
        if didSave {
359
            reloadChargedDevices()
360
        }
361

            
362
        return didSave
363
    }
364

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

            
391
        if didSave {
392
            reloadChargedDevices()
393
        }
394

            
395
        return didSave
396
    }
397

            
398
    @discardableResult
Bogdan Timofte authored a month ago
399
    func updateCharger(
400
        id: UUID,
401
        name: String,
Bogdan Timofte authored a month ago
402
        templateID: String?,
Bogdan Timofte authored a month ago
403
        notes: String?
404
    ) -> Bool {
405
        let didSave = chargeInsightsStore?.updateCharger(
406
            id: id,
407
            name: name,
Bogdan Timofte authored a month ago
408
            templateID: templateID,
Bogdan Timofte authored a month ago
409
            notes: notes
Bogdan Timofte authored a month ago
410
        ) ?? false
411

            
412
        if didSave {
413
            reloadChargedDevices()
414
        }
415

            
416
        return didSave
417
    }
418

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

            
428
    @discardableResult
429
    func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
430
        let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false
431
        if didSave {
432
            reloadChargedDevices()
433
        }
434
        return didSave
435
    }
436

            
Bogdan Timofte authored a month ago
437
    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
438
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
439
            return
440
        }
441
        guard activeSession.status == .active else {
442
            return
443
        }
444
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
445
    }
446

            
Bogdan Timofte authored a month ago
447
    @discardableResult
Bogdan Timofte authored a month ago
448
    func startChargeSession(
449
        for meter: Meter,
450
        chargedDeviceID: UUID,
451
        chargerID: UUID?,
452
        chargingTransportMode: ChargingTransportMode,
453
        chargingStateMode: ChargingStateMode,
454
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
455
        initialBatteryPercent: Double?,
456
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
457
    ) -> Bool {
Bogdan Timofte authored a month ago
458
        meter.resetMeterCountersForNewSession()
459

            
Bogdan Timofte authored a month ago
460
        guard let snapshot = meter.chargingMonitorSnapshot else {
461
            return false
462
        }
463

            
Bogdan Timofte authored a month ago
464
        let didSave = chargeInsightsStore?.startSession(
465
            for: snapshot,
466
            chargedDeviceID: chargedDeviceID,
467
            chargerID: chargerID,
468
            chargingTransportMode: chargingTransportMode,
469
            chargingStateMode: chargingStateMode,
470
            autoStopEnabled: autoStopEnabled,
Bogdan Timofte authored a month ago
471
            initialBatteryPercent: initialBatteryPercent,
472
            startsFromFlatBattery: startsFromFlatBattery
Bogdan Timofte authored a month ago
473
        ) ?? false
474
        if didSave {
475
            reloadChargedDevices()
Bogdan Timofte authored a month ago
476
            meter.resetChargeRecordGraph()
477
            if let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description),
478
               meter.supportsRecordingThreshold,
479
               activeSession.stopThresholdAmps > 0 {
480
                meter.recordingTreshold = activeSession.stopThresholdAmps
481
            }
482
            restoreChargeMonitoringStateIfNeeded(for: meter)
Bogdan Timofte authored a month ago
483
        }
484
        return didSave
485
    }
486

            
487
    @discardableResult
488
    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
489
        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
490
        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
491
        if didSave {
492
            reloadChargedDevices()
493
        }
494
        return didSave
495
    }
496

            
497
    @discardableResult
498
    func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
499
        let snapshot = meter?.chargingMonitorSnapshot
500
        let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
501
        if didSave {
502
            reloadChargedDevices()
503
        }
504
        return didSave
505
    }
506

            
507
    @discardableResult
Bogdan Timofte authored a month ago
508
    func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil) -> Bool {
Bogdan Timofte authored a month ago
509
        let didSave = chargeInsightsStore?.stopSession(
510
            id: sessionID,
Bogdan Timofte authored a month ago
511
            finalBatteryPercent: finalBatteryPercent
Bogdan Timofte authored a month ago
512
        ) ?? false
Bogdan Timofte authored a month ago
513
        reloadChargedDevices()
Bogdan Timofte authored a month ago
514
        return didSave
515
    }
516

            
517
    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
518
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
519
            return
520
        }
521

            
522
        if chargeInsightsStore?.observe(snapshot: snapshot) == true {
523
            reloadChargedDevices()
524
        }
525
    }
526

            
527
    @discardableResult
Bogdan Timofte authored a month ago
528
    func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
Bogdan Timofte authored a month ago
529
        observeChargeSnapshot(from: meter)
530

            
531
        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
532
        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
533
        let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
534

            
Bogdan Timofte authored a month ago
535
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
536
            percent: percent,
Bogdan Timofte authored a month ago
537
            for: meter.btSerial.macAddress.description,
538
            measuredEnergyWh: checkpointEnergyWh,
539
            measuredChargeAh: checkpointChargeAh
Bogdan Timofte authored a month ago
540
        ) ?? false
541

            
542
        if didSave {
543
            reloadChargedDevices()
544
        }
545

            
546
        return didSave
547
    }
548

            
549
    @discardableResult
Bogdan Timofte authored a month ago
550
    func addBatteryCheckpoint(percent: Double, for sessionID: UUID) -> Bool {
551
        guard canAddBatteryCheckpoint(to: sessionID) else {
552
            return false
553
        }
554

            
Bogdan Timofte authored a month ago
555
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
556
            percent: percent,
557
            for: sessionID
558
        ) ?? false
559

            
560
        if didSave {
561
            reloadChargedDevices()
562
        }
563

            
564
        return didSave
565
    }
566

            
Bogdan Timofte authored a month ago
567
    @discardableResult
568
    func addBatteryCheckpoint(
569
        percent: Double,
570
        for sessionID: UUID,
571
        measuredEnergyWh: Double?,
572
        measuredChargeAh: Double?
573
    ) -> Bool {
Bogdan Timofte authored a month ago
574
        guard canAddBatteryCheckpoint(to: sessionID) else {
575
            return false
576
        }
577

            
Bogdan Timofte authored a month ago
578
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
579
            percent: percent,
580
            for: sessionID,
581
            measuredEnergyWh: measuredEnergyWh,
582
            measuredChargeAh: measuredChargeAh
583
        ) ?? false
584

            
585
        if didSave {
586
            reloadChargedDevices()
587
        }
588

            
589
        return didSave
590
    }
591

            
Bogdan Timofte authored a month ago
592
    func canAddBatteryCheckpoint(to sessionID: UUID) -> Bool {
593
        guard let session = chargeSessionSummary(id: sessionID),
594
              session.status.isOpen,
595
              let meterMACAddress = session.meterMACAddress else {
596
            return false
597
        }
598

            
599
        return meter(for: meterMACAddress) != nil
600
    }
601

            
602
    func batteryCheckpointCaptureRequirementMessage(for sessionID: UUID) -> String? {
603
        guard let session = chargeSessionSummary(id: sessionID) else {
604
            return "Battery checkpoints are available only while the charge session is still active."
605
        }
606

            
607
        guard session.status.isOpen else {
608
            return "Battery checkpoints are available only while the charge session is still active."
609
        }
610

            
611
        guard let meterMACAddress = session.meterMACAddress,
612
              meter(for: meterMACAddress) != nil else {
613
            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."
614
        }
615

            
616
        return nil
617
    }
618

            
Bogdan Timofte authored a month ago
619
    func batteryCheckpointPlausibilityWarning(
620
        percent: Double,
Bogdan Timofte authored a month ago
621
        for sessionID: UUID,
622
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
623
    ) -> BatteryCheckpointPlausibilityWarning? {
624
        guard let session = chargeSessionSummary(id: sessionID) else {
625
            return nil
626
        }
Bogdan Timofte authored a month ago
627
        return batteryCheckpointPlausibilityWarning(
628
            percent: percent,
629
            for: session,
630
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
631
        )
Bogdan Timofte authored a month ago
632
    }
633

            
634
    @discardableResult
635
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
636
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
637
            id: checkpointID,
638
            from: sessionID
639
        ) ?? false
640

            
641
        if didDelete {
Bogdan Timofte authored a month ago
642
            scheduleChargedDevicesReload(delay: 0)
Bogdan Timofte authored a month ago
643
        }
644

            
645
        return didDelete
646
    }
647

            
Bogdan Timofte authored a month ago
648
    @discardableResult
649
    func flushChargeInsights() -> Bool {
650
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
651
        reloadChargedDevices()
652
        return didSave
653
    }
654

            
655
    @discardableResult
656
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
657
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
658
            return false
659
        }
660
        return setTargetBatteryPercent(percent, for: activeSession.id)
661
    }
662

            
663
    @discardableResult
664
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
665
        if percent != nil {
666
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
667
        }
668

            
669
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
670
        if didSave {
671
            reloadChargedDevices()
672
        }
673
        return didSave
674
    }
675

            
676
    @discardableResult
677
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
678
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
679
        if didSave {
680
            reloadChargedDevices()
681
        }
682
        return didSave
683
    }
684

            
685
    @discardableResult
686
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
687
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
688
        if didSave {
689
            reloadChargedDevices()
690
        }
691
        return didSave
692
    }
693

            
694
    @discardableResult
695
    func deleteChargeSession(sessionID: UUID) -> Bool {
696
        let deletedSession = chargedDevices
697
            .flatMap(\.sessions)
698
            .first(where: { $0.id == sessionID })
699

            
700
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
701
        guard didDelete else {
702
            return false
703
        }
704

            
Bogdan Timofte authored a month ago
705
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
706
           let meterMACAddress = deletedSession?.meterMACAddress,
707
           let liveMeter = meter(for: meterMACAddress) {
708
            liveMeter.resetChargeRecord()
709
        }
710

            
711
        reloadChargedDevices()
712
        return true
713
    }
714

            
715
    @discardableResult
716
    func deleteChargedDevice(id: UUID) -> Bool {
717
        let deletedDevice = chargedDeviceSummary(id: id)
718
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
719
        guard didDelete else {
720
            return false
721
        }
722

            
Bogdan Timofte authored a month ago
723
        if deletedDevice?.isCharger == true {
724
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
725
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
726
                session.stop()
727
                activeChargerStandbySessions[meterMACAddress] = nil
728
            }
729
        }
730

            
Bogdan Timofte authored a month ago
731
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
732
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
733
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
734
           let liveMeter = meter(for: meterMACAddress) {
735
            liveMeter.resetChargeRecord()
736
        }
737

            
738
        reloadChargedDevices()
739
        return true
740
    }
741

            
742
    @discardableResult
743
    func createKnownMeter(
744
        macAddress: String,
745
        customName: String?,
746
        modelName: String,
747
        advertisedName: String?
748
    ) -> Bool {
749
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
750
        guard Self.isValidMACAddress(normalizedMAC) else {
751
            return false
752
        }
753

            
754
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
755
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
756
            setMeterName(customName, for: normalizedMAC)
757
        }
758
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
759
        return true
760
    }
761

            
762
    @discardableResult
763
    func deleteMeter(macAddress: String) -> Bool {
764
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
765
        guard Self.isValidMACAddress(normalizedMAC) else {
766
            return false
767
        }
768

            
769
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
770
            meter.disconnect()
771
        }
772
        meters = meters.filter { element in
773
            element.value.btSerial.macAddress.description != normalizedMAC
774
        }
775

            
776
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
777
        if didDelete {
778
            scheduleObjectWillChange()
779
        }
780
        return didDelete
781
    }
782

            
Bogdan Timofte authored 2 months ago
783
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored 2 months ago
784
        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
785
        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
786
        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
787

            
788
        return macAddresses.map { macAddress in
789
            let liveMeter = liveMetersByMAC[macAddress]
790
            let record = recordsByMAC[macAddress]
791

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

            
Bogdan Timofte authored 2 months ago
817
    private func scheduleObjectWillChange() {
818
        DispatchQueue.main.async { [weak self] in
819
            self?.objectWillChange.send()
820
        }
821
    }
Bogdan Timofte authored 2 months ago
822

            
Bogdan Timofte authored a month ago
823
    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
824
        pendingChargedDevicesReloadWorkItem?.cancel()
825

            
826
        let workItem = DispatchWorkItem { [weak self] in
827
            self?.reloadChargedDevices()
828
        }
829
        pendingChargedDevicesReloadWorkItem = workItem
830
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
831
    }
832

            
833
    private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
834
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
835
        guard !normalizedMAC.isEmpty else {
836
            return nil
837
        }
838

            
839
        return chargedDevices
840
            .lazy
841
            .compactMap(\.activeSession)
Bogdan Timofte authored a month ago
842
            .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
843
    }
844

            
Bogdan Timofte authored a month ago
845
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
846
        pendingChargedDevicesReloadWorkItem?.cancel()
847
        pendingChargedDevicesReloadWorkItem = nil
848

            
Bogdan Timofte authored a month ago
849
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
850
        chargedDevices = (chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
851
            chargedDevice.withStandbyPowerMeasurements(
852
                standbyMeasurementsByChargerID[chargedDevice.id] ?? []
853
            )
854
        }
Bogdan Timofte authored a month ago
855
        chargeNotificationCoordinator.process(chargedDevices: deviceSummaries)
856
        for meter in meters.values {
857
            restoreChargeMonitoringStateIfNeeded(for: meter)
858
        }
859
    }
860

            
861
    private func meter(for meterMACAddress: String) -> Meter? {
862
        meters.values.first { meter in
863
            meter.btSerial.macAddress.description == meterMACAddress
864
        }
865
    }
866

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

            
879
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
880
                meter.reloadTemperatureUnitPreference()
881
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
882
                    didUpdateAnyMeter = true
883
                }
884
            }
885

            
886
            if didUpdateAnyMeter {
887
                self.scheduleObjectWillChange()
888
            }
889
        }
890
    }
Bogdan Timofte authored a month ago
891

            
Bogdan Timofte authored a month ago
892
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
893
        guard let charger = chargedDeviceSummary(id: session.chargerID),
894
              let statistics = session.statistics else {
895
            return
896
        }
897

            
898
        let content = UNMutableNotificationContent()
899
        content.title = "Standby baseline stabilised"
900
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
901
        content.sound = .default
902
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
903

            
904
        let request = UNNotificationRequest(
905
            identifier: "charger-standby-\(session.id.uuidString)",
906
            content: content,
907
            trigger: nil
908
        )
909
        UNUserNotificationCenter.current().add(request)
910
        scheduleObjectWillChange()
911
    }
912

            
Bogdan Timofte authored a month ago
913
    private func batteryCheckpointPlausibilityWarning(
914
        percent: Double,
Bogdan Timofte authored a month ago
915
        for session: ChargeSessionSummary,
916
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
917
    ) -> BatteryCheckpointPlausibilityWarning? {
918
        guard percent.isFinite, percent >= 0, percent <= 100 else {
919
            return nil
920
        }
921

            
922
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
923
            if lhs.timestamp != rhs.timestamp {
924
                return lhs.timestamp < rhs.timestamp
925
            }
926
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
927
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
928
            }
929
            return lhs.id.uuidString < rhs.id.uuidString
930
        }
931

            
932
        if let lastCheckpoint = sortedCheckpoints.last,
933
           percent < lastCheckpoint.batteryPercent - 1.5 {
934
            return BatteryCheckpointPlausibilityWarning(
935
                title: "Checkpoint Goes Backwards",
936
                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."
937
            )
938
        }
939

            
Bogdan Timofte authored a month ago
940
        let effectiveEnergyWh = effectiveEnergyWhOverride
941
            ?? session.effectiveBatteryEnergyWh
942
            ?? session.measuredEnergyWh
943

            
944
        if let lastCheckpoint = sortedCheckpoints.last,
945
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
946
            let estimatedCapacityWh = session.capacityEstimateWh
947
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
948
                ?? chargedDevice.estimatedBatteryCapacityWh
949

            
950
            if let estimatedCapacityWh, estimatedCapacityWh > 0 {
951
                let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
952
                let expectedPercent = min(
953
                    100,
954
                    max(
955
                        lastCheckpoint.batteryPercent,
956
                        lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
957
                    )
958
                )
959
                let predictionGap = percent - expectedPercent
960
                guard abs(predictionGap) >= 4 else {
961
                    return nil
962
                }
963

            
964
                let direction = predictionGap > 0 ? "above" : "below"
965
                let gapText = abs(predictionGap).format(decimalDigits: 0)
966
                let expectedText = expectedPercent.format(decimalDigits: 0)
967

            
968
                return BatteryCheckpointPlausibilityWarning(
969
                    title: "Checkpoint Looks Implausible",
970
                    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."
971
                )
972
            }
973
        }
974

            
Bogdan Timofte authored a month ago
975
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
Bogdan Timofte authored a month ago
976
              let prediction = chargedDevice.batteryLevelPrediction(
977
                for: session,
978
                effectiveEnergyWhOverride: effectiveEnergyWh
979
              )
Bogdan Timofte authored a month ago
980
        else {
981
            return nil
982
        }
983

            
984
        let predictionGap = percent - prediction.predictedPercent
985
        guard abs(predictionGap) >= 4 else {
986
            return nil
987
        }
988

            
989
        let direction = predictionGap > 0 ? "above" : "below"
990
        let gapText = abs(predictionGap).format(decimalDigits: 0)
991
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
992

            
993
        if let lastCheckpoint = sortedCheckpoints.last {
994
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
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). Since the last checkpoint at \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))%, only \(energyDeltaWh.format(decimalDigits: 2)) Wh were added."
998
            )
999
        }
1000

            
1001
        return BatteryCheckpointPlausibilityWarning(
1002
            title: "Checkpoint Looks Implausible",
1003
            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."
1004
        )
1005
    }
Bogdan Timofte authored a month ago
1006

            
1007
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1008
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1009
        guard session.status.isOpen else {
1010
            return storedEnergyWh
1011
        }
1012

            
1013
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1014
            return storedEnergyWh
1015
        }
1016

            
1017
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1018
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
1019
        }
1020

            
1021
        return storedEnergyWh
1022
    }
1023

            
1024
    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1025
        let storedChargeAh = session.measuredChargeAh
1026
        guard session.status.isOpen else {
1027
            return storedChargeAh
1028
        }
1029

            
1030
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1031
            return storedChargeAh
1032
        }
1033

            
1034
        if let baselineChargeAh = session.meterChargeBaselineAh {
1035
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
1036
        }
1037

            
1038
        return storedChargeAh
1039
    }
Bogdan Timofte authored 2 months ago
1040
}
Bogdan Timofte authored 2 months ago
1041

            
1042
extension AppData.MeterSummary {
1043
    var tint: Color {
1044
        switch modelSummary {
1045
        case "UM25C":
1046
            return .blue
1047
        case "UM34C":
1048
            return .yellow
1049
        case "TC66C":
1050
            return Model.TC66C.color
1051
        default:
1052
            return .secondary
1053
        }
1054
    }
1055
}
Bogdan Timofte authored 2 months ago
1056

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

            
1077
    static func normalizedMACAddress(_ macAddress: String) -> String {
1078
        macAddress
1079
            .trimmingCharacters(in: .whitespacesAndNewlines)
1080
            .uppercased()
1081
    }
1082

            
1083
    static func isValidMACAddress(_ macAddress: String) -> Bool {
1084
        macAddress.range(
1085
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
1086
            options: .regularExpression
1087
        ) != nil
1088
    }
1089
}
1090

            
1091
private final class ChargeNotificationCoordinator {
1092
    private struct Payload {
1093
        let id: String
1094
        let title: String
1095
        let body: String
1096
        let threadIdentifier: String
1097
    }
1098

            
1099
    private let notificationCenter = UNUserNotificationCenter.current()
1100
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
1101
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
1102
    private var inFlightEventIDs: Set<String> = []
1103

            
1104
    func ensureAuthorizationIfNeeded() {
1105
        notificationCenter.getNotificationSettings { [weak self] settings in
1106
            guard settings.authorizationStatus == .notDetermined else {
1107
                return
1108
            }
1109

            
1110
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
1111
                if let error {
1112
                    track("Notification authorization request failed: \(error.localizedDescription)")
1113
                }
1114
            }
1115
        }
1116
    }
1117

            
1118
    func process(chargedDevices: [ChargedDeviceSummary]) {
1119
        let now = Date()
1120
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
1121
            payloads(for: chargedDevice, now: now)
1122
        }
1123

            
1124
        for payload in pendingPayloads {
1125
            scheduleIfNeeded(payload)
1126
        }
1127
    }
1128

            
1129
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1130
        chargedDevice.sessions.compactMap { session in
1131
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1132
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1133
               let targetBatteryPercent = session.targetBatteryPercent {
1134
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1135
                    ?? session.endBatteryPercent
1136
                    ?? targetBatteryPercent
1137

            
1138
                return Payload(
1139
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1140
                    title: "Battery target reached",
1141
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1142
                    threadIdentifier: session.id.uuidString
1143
                )
1144
            }
1145

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

            
1156
                return Payload(
1157
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1158
                    title: "Confirm charge completion",
1159
                    body: bodyPrefix + detail,
1160
                    threadIdentifier: session.id.uuidString
1161
                )
1162
            }
1163

            
1164
            return nil
1165
        }
1166
    }
1167

            
1168
    private func scheduleIfNeeded(_ payload: Payload) {
1169
        guard deliveredEventIDs().contains(payload.id) == false else {
1170
            return
1171
        }
1172

            
1173
        guard inFlightEventIDs.contains(payload.id) == false else {
1174
            return
1175
        }
1176

            
1177
        inFlightEventIDs.insert(payload.id)
1178

            
1179
        notificationCenter.getNotificationSettings { [weak self] settings in
1180
            guard let self else { return }
1181
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1182
                DispatchQueue.main.async {
1183
                    self.inFlightEventIDs.remove(payload.id)
1184
                }
1185
                return
1186
            }
1187

            
1188
            let content = UNMutableNotificationContent()
1189
            content.title = payload.title
1190
            content.body = payload.body
1191
            content.sound = .default
1192
            content.threadIdentifier = payload.threadIdentifier
1193

            
1194
            let request = UNNotificationRequest(
1195
                identifier: payload.id,
1196
                content: content,
1197
                trigger: nil
1198
            )
1199

            
1200
            self.notificationCenter.add(request) { error in
1201
                DispatchQueue.main.async {
1202
                    self.inFlightEventIDs.remove(payload.id)
1203
                    if let error {
1204
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1205
                        return
1206
                    }
1207
                    self.storeDeliveredEventID(payload.id)
1208
                }
1209
            }
1210
        }
1211
    }
1212

            
1213
    private func deliveredEventIDs() -> Set<String> {
1214
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1215
        return Set(values)
1216
    }
1217

            
1218
    private func storeDeliveredEventID(_ id: String) {
1219
        var values = deliveredEventIDs()
1220
        values.insert(id)
1221
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1222
    }
Bogdan Timofte authored 2 months ago
1223
}