Newer Older
1191 lines | 43.259kb
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
500
    func stopChargeSession(
501
        sessionID: UUID,
502
        finalBatteryPercent: Double,
503
        label: String? = "Final"
504
    ) -> Bool {
505
        let didSave = chargeInsightsStore?.stopSession(
506
            id: sessionID,
507
            finalBatteryPercent: finalBatteryPercent,
508
            label: label
509
        ) ?? false
Bogdan Timofte authored a month ago
510
        if didSave {
511
            reloadChargedDevices()
512
        }
513
        return didSave
514
    }
515

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

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

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

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

            
Bogdan Timofte authored a month ago
534
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
535
            percent: percent,
536
            label: label,
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
550
    func addBatteryCheckpoint(percent: Double, label: String?, for sessionID: UUID) -> Bool {
551
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
552
            percent: percent,
553
            label: label,
554
            for: sessionID
555
        ) ?? false
556

            
557
        if didSave {
558
            reloadChargedDevices()
559
        }
560

            
561
        return didSave
562
    }
563

            
Bogdan Timofte authored a month ago
564
    @discardableResult
565
    func addBatteryCheckpoint(
566
        percent: Double,
567
        label: String?,
568
        for sessionID: UUID,
569
        measuredEnergyWh: Double?,
570
        measuredChargeAh: Double?
571
    ) -> Bool {
572
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
573
            percent: percent,
574
            label: label,
575
            for: sessionID,
576
            measuredEnergyWh: measuredEnergyWh,
577
            measuredChargeAh: measuredChargeAh
578
        ) ?? false
579

            
580
        if didSave {
581
            reloadChargedDevices()
582
        }
583

            
584
        return didSave
585
    }
586

            
Bogdan Timofte authored a month ago
587
    func batteryCheckpointPlausibilityWarning(
588
        percent: Double,
Bogdan Timofte authored a month ago
589
        for sessionID: UUID,
590
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
591
    ) -> BatteryCheckpointPlausibilityWarning? {
592
        guard let session = chargeSessionSummary(id: sessionID) else {
593
            return nil
594
        }
Bogdan Timofte authored a month ago
595
        return batteryCheckpointPlausibilityWarning(
596
            percent: percent,
597
            for: session,
598
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
599
        )
Bogdan Timofte authored a month ago
600
    }
601

            
602
    @discardableResult
603
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
604
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
605
            id: checkpointID,
606
            from: sessionID
607
        ) ?? false
608

            
609
        if didDelete {
Bogdan Timofte authored a month ago
610
            scheduleChargedDevicesReload(delay: 0)
Bogdan Timofte authored a month ago
611
        }
612

            
613
        return didDelete
614
    }
615

            
Bogdan Timofte authored a month ago
616
    @discardableResult
617
    func flushChargeInsights() -> Bool {
618
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
619
        reloadChargedDevices()
620
        return didSave
621
    }
622

            
623
    @discardableResult
624
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
625
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
626
            return false
627
        }
628
        return setTargetBatteryPercent(percent, for: activeSession.id)
629
    }
630

            
631
    @discardableResult
632
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
633
        if percent != nil {
634
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
635
        }
636

            
637
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
638
        if didSave {
639
            reloadChargedDevices()
640
        }
641
        return didSave
642
    }
643

            
644
    @discardableResult
645
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
646
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
647
        if didSave {
648
            reloadChargedDevices()
649
        }
650
        return didSave
651
    }
652

            
653
    @discardableResult
654
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
655
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
656
        if didSave {
657
            reloadChargedDevices()
658
        }
659
        return didSave
660
    }
661

            
662
    @discardableResult
663
    func deleteChargeSession(sessionID: UUID) -> Bool {
664
        let deletedSession = chargedDevices
665
            .flatMap(\.sessions)
666
            .first(where: { $0.id == sessionID })
667

            
668
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
669
        guard didDelete else {
670
            return false
671
        }
672

            
Bogdan Timofte authored a month ago
673
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
674
           let meterMACAddress = deletedSession?.meterMACAddress,
675
           let liveMeter = meter(for: meterMACAddress) {
676
            liveMeter.resetChargeRecord()
677
        }
678

            
679
        reloadChargedDevices()
680
        return true
681
    }
682

            
683
    @discardableResult
684
    func deleteChargedDevice(id: UUID) -> Bool {
685
        let deletedDevice = chargedDeviceSummary(id: id)
686
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
687
        guard didDelete else {
688
            return false
689
        }
690

            
Bogdan Timofte authored a month ago
691
        if deletedDevice?.isCharger == true {
692
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
693
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
694
                session.stop()
695
                activeChargerStandbySessions[meterMACAddress] = nil
696
            }
697
        }
698

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

            
706
        reloadChargedDevices()
707
        return true
708
    }
709

            
710
    @discardableResult
711
    func createKnownMeter(
712
        macAddress: String,
713
        customName: String?,
714
        modelName: String,
715
        advertisedName: String?
716
    ) -> Bool {
717
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
718
        guard Self.isValidMACAddress(normalizedMAC) else {
719
            return false
720
        }
721

            
722
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
723
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
724
            setMeterName(customName, for: normalizedMAC)
725
        }
726
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
727
        return true
728
    }
729

            
730
    @discardableResult
731
    func deleteMeter(macAddress: String) -> Bool {
732
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
733
        guard Self.isValidMACAddress(normalizedMAC) else {
734
            return false
735
        }
736

            
737
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
738
            meter.disconnect()
739
        }
740
        meters = meters.filter { element in
741
            element.value.btSerial.macAddress.description != normalizedMAC
742
        }
743

            
744
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
745
        if didDelete {
746
            scheduleObjectWillChange()
747
        }
748
        return didDelete
749
    }
750

            
Bogdan Timofte authored 2 months ago
751
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored 2 months ago
752
        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
753
        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
754
        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
755

            
756
        return macAddresses.map { macAddress in
757
            let liveMeter = liveMetersByMAC[macAddress]
758
            let record = recordsByMAC[macAddress]
759

            
Bogdan Timofte authored 2 months ago
760
            return MeterSummary(
Bogdan Timofte authored 2 months ago
761
                macAddress: macAddress,
Bogdan Timofte authored 2 months ago
762
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
Bogdan Timofte authored 2 months ago
763
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
Bogdan Timofte authored 2 months ago
764
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
765
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
766
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
767
                meter: liveMeter
768
            )
769
        }
770
        .sorted { lhs, rhs in
Bogdan Timofte authored 2 months ago
771
            if lhs.meter != nil && rhs.meter == nil {
772
                return true
773
            }
774
            if lhs.meter == nil && rhs.meter != nil {
775
                return false
776
            }
Bogdan Timofte authored 2 months ago
777
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
778
            if byName != .orderedSame {
779
                return byName == .orderedAscending
780
            }
781
            return lhs.macAddress < rhs.macAddress
782
        }
783
    }
784

            
Bogdan Timofte authored 2 months ago
785
    private func scheduleObjectWillChange() {
786
        DispatchQueue.main.async { [weak self] in
787
            self?.objectWillChange.send()
788
        }
789
    }
Bogdan Timofte authored 2 months ago
790

            
Bogdan Timofte authored a month ago
791
    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
792
        pendingChargedDevicesReloadWorkItem?.cancel()
793

            
794
        let workItem = DispatchWorkItem { [weak self] in
795
            self?.reloadChargedDevices()
796
        }
797
        pendingChargedDevicesReloadWorkItem = workItem
798
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
799
    }
800

            
801
    private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
802
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
803
        guard !normalizedMAC.isEmpty else {
804
            return nil
805
        }
806

            
807
        return chargedDevices
808
            .lazy
809
            .compactMap(\.activeSession)
810
            .first(where: { $0.status == .active && $0.meterMACAddress == normalizedMAC })
811
    }
812

            
Bogdan Timofte authored a month ago
813
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
814
        pendingChargedDevicesReloadWorkItem?.cancel()
815
        pendingChargedDevicesReloadWorkItem = nil
816

            
Bogdan Timofte authored a month ago
817
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
818
        chargedDevices = (chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
819
            chargedDevice.withStandbyPowerMeasurements(
820
                standbyMeasurementsByChargerID[chargedDevice.id] ?? []
821
            )
822
        }
Bogdan Timofte authored a month ago
823
        chargeNotificationCoordinator.process(chargedDevices: deviceSummaries)
824
        for meter in meters.values {
825
            restoreChargeMonitoringStateIfNeeded(for: meter)
826
        }
827
    }
828

            
829
    private func meter(for meterMACAddress: String) -> Meter? {
830
        meters.values.first { meter in
831
            meter.btSerial.macAddress.description == meterMACAddress
832
        }
833
    }
834

            
Bogdan Timofte authored 2 months ago
835
    private func refreshMeterMetadata() {
836
        DispatchQueue.main.async { [weak self] in
837
            guard let self else { return }
838
            var didUpdateAnyMeter = false
839
            for meter in self.meters.values {
840
                let mac = meter.btSerial.macAddress.description
841
                let displayName = self.meterName(for: mac) ?? mac
842
                if meter.name != displayName {
843
                    meter.updateNameFromStore(displayName)
844
                    didUpdateAnyMeter = true
845
                }
846

            
847
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
848
                meter.reloadTemperatureUnitPreference()
849
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
850
                    didUpdateAnyMeter = true
851
                }
852
            }
853

            
854
            if didUpdateAnyMeter {
855
                self.scheduleObjectWillChange()
856
            }
857
        }
858
    }
Bogdan Timofte authored a month ago
859

            
Bogdan Timofte authored a month ago
860
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
861
        guard let charger = chargedDeviceSummary(id: session.chargerID),
862
              let statistics = session.statistics else {
863
            return
864
        }
865

            
866
        let content = UNMutableNotificationContent()
867
        content.title = "Standby baseline stabilised"
868
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
869
        content.sound = .default
870
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
871

            
872
        let request = UNNotificationRequest(
873
            identifier: "charger-standby-\(session.id.uuidString)",
874
            content: content,
875
            trigger: nil
876
        )
877
        UNUserNotificationCenter.current().add(request)
878
        scheduleObjectWillChange()
879
    }
880

            
Bogdan Timofte authored a month ago
881
    private func batteryCheckpointPlausibilityWarning(
882
        percent: Double,
Bogdan Timofte authored a month ago
883
        for session: ChargeSessionSummary,
884
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
885
    ) -> BatteryCheckpointPlausibilityWarning? {
886
        guard percent.isFinite, percent >= 0, percent <= 100 else {
887
            return nil
888
        }
889

            
890
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
891
            if lhs.timestamp != rhs.timestamp {
892
                return lhs.timestamp < rhs.timestamp
893
            }
894
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
895
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
896
            }
897
            return lhs.id.uuidString < rhs.id.uuidString
898
        }
899

            
900
        if let lastCheckpoint = sortedCheckpoints.last,
901
           percent < lastCheckpoint.batteryPercent - 1.5 {
902
            return BatteryCheckpointPlausibilityWarning(
903
                title: "Checkpoint Goes Backwards",
904
                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."
905
            )
906
        }
907

            
Bogdan Timofte authored a month ago
908
        let effectiveEnergyWh = effectiveEnergyWhOverride
909
            ?? session.effectiveBatteryEnergyWh
910
            ?? session.measuredEnergyWh
911

            
912
        if let lastCheckpoint = sortedCheckpoints.last,
913
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
914
            let estimatedCapacityWh = session.capacityEstimateWh
915
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
916
                ?? chargedDevice.estimatedBatteryCapacityWh
917

            
918
            if let estimatedCapacityWh, estimatedCapacityWh > 0 {
919
                let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
920
                let expectedPercent = min(
921
                    100,
922
                    max(
923
                        lastCheckpoint.batteryPercent,
924
                        lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
925
                    )
926
                )
927
                let predictionGap = percent - expectedPercent
928
                guard abs(predictionGap) >= 4 else {
929
                    return nil
930
                }
931

            
932
                let direction = predictionGap > 0 ? "above" : "below"
933
                let gapText = abs(predictionGap).format(decimalDigits: 0)
934
                let expectedText = expectedPercent.format(decimalDigits: 0)
935

            
936
                return BatteryCheckpointPlausibilityWarning(
937
                    title: "Checkpoint Looks Implausible",
938
                    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."
939
                )
940
            }
941
        }
942

            
Bogdan Timofte authored a month ago
943
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
Bogdan Timofte authored a month ago
944
              let prediction = chargedDevice.batteryLevelPrediction(
945
                for: session,
946
                effectiveEnergyWhOverride: effectiveEnergyWh
947
              )
Bogdan Timofte authored a month ago
948
        else {
949
            return nil
950
        }
951

            
952
        let predictionGap = percent - prediction.predictedPercent
953
        guard abs(predictionGap) >= 4 else {
954
            return nil
955
        }
956

            
957
        let direction = predictionGap > 0 ? "above" : "below"
958
        let gapText = abs(predictionGap).format(decimalDigits: 0)
959
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
960

            
961
        if let lastCheckpoint = sortedCheckpoints.last {
962
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
963
            return BatteryCheckpointPlausibilityWarning(
964
                title: "Checkpoint Looks Implausible",
965
                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."
966
            )
967
        }
968

            
969
        return BatteryCheckpointPlausibilityWarning(
970
            title: "Checkpoint Looks Implausible",
971
            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."
972
        )
973
    }
Bogdan Timofte authored a month ago
974

            
975
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
976
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
977
        guard session.status.isOpen else {
978
            return storedEnergyWh
979
        }
980

            
981
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
982
            return storedEnergyWh
983
        }
984

            
985
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
986
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
987
        }
988

            
989
        return storedEnergyWh
990
    }
991

            
992
    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
993
        let storedChargeAh = session.measuredChargeAh
994
        guard session.status.isOpen else {
995
            return storedChargeAh
996
        }
997

            
998
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
999
            return storedChargeAh
1000
        }
1001

            
1002
        if let baselineChargeAh = session.meterChargeBaselineAh {
1003
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
1004
        }
1005

            
1006
        return storedChargeAh
1007
    }
Bogdan Timofte authored 2 months ago
1008
}
Bogdan Timofte authored 2 months ago
1009

            
1010
extension AppData.MeterSummary {
1011
    var tint: Color {
1012
        switch modelSummary {
1013
        case "UM25C":
1014
            return .blue
1015
        case "UM34C":
1016
            return .yellow
1017
        case "TC66C":
1018
            return Model.TC66C.color
1019
        default:
1020
            return .secondary
1021
        }
1022
    }
1023
}
Bogdan Timofte authored 2 months ago
1024

            
Bogdan Timofte authored a month ago
1025
extension AppData {
Bogdan Timofte authored 2 months ago
1026
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
1027
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
1028
            return liveName
1029
        }
1030
        if let customName = record?.customName {
1031
            return customName
1032
        }
1033
        if let advertisedName = record?.advertisedName {
1034
            return advertisedName
1035
        }
1036
        if let recordModel = record?.modelName {
1037
            return recordModel
1038
        }
1039
        if let liveModel = liveMeter?.deviceModelSummary {
1040
            return liveModel
1041
        }
1042
        return "Meter"
1043
    }
Bogdan Timofte authored a month ago
1044

            
1045
    static func normalizedMACAddress(_ macAddress: String) -> String {
1046
        macAddress
1047
            .trimmingCharacters(in: .whitespacesAndNewlines)
1048
            .uppercased()
1049
    }
1050

            
1051
    static func isValidMACAddress(_ macAddress: String) -> Bool {
1052
        macAddress.range(
1053
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
1054
            options: .regularExpression
1055
        ) != nil
1056
    }
1057
}
1058

            
1059
private final class ChargeNotificationCoordinator {
1060
    private struct Payload {
1061
        let id: String
1062
        let title: String
1063
        let body: String
1064
        let threadIdentifier: String
1065
    }
1066

            
1067
    private let notificationCenter = UNUserNotificationCenter.current()
1068
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
1069
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
1070
    private var inFlightEventIDs: Set<String> = []
1071

            
1072
    func ensureAuthorizationIfNeeded() {
1073
        notificationCenter.getNotificationSettings { [weak self] settings in
1074
            guard settings.authorizationStatus == .notDetermined else {
1075
                return
1076
            }
1077

            
1078
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
1079
                if let error {
1080
                    track("Notification authorization request failed: \(error.localizedDescription)")
1081
                }
1082
            }
1083
        }
1084
    }
1085

            
1086
    func process(chargedDevices: [ChargedDeviceSummary]) {
1087
        let now = Date()
1088
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
1089
            payloads(for: chargedDevice, now: now)
1090
        }
1091

            
1092
        for payload in pendingPayloads {
1093
            scheduleIfNeeded(payload)
1094
        }
1095
    }
1096

            
1097
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1098
        chargedDevice.sessions.compactMap { session in
1099
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1100
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1101
               let targetBatteryPercent = session.targetBatteryPercent {
1102
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1103
                    ?? session.endBatteryPercent
1104
                    ?? targetBatteryPercent
1105

            
1106
                return Payload(
1107
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1108
                    title: "Battery target reached",
1109
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1110
                    threadIdentifier: session.id.uuidString
1111
                )
1112
            }
1113

            
1114
            if session.requiresCompletionConfirmation,
1115
               let requestedAt = session.completionConfirmationRequestedAt,
1116
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
1117
                let estimatedPercent = session.completionContradictionPercent
1118
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1119
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
1120
                let detail = estimatedPercent.map {
1121
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
1122
                } ?? ""
1123

            
1124
                return Payload(
1125
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1126
                    title: "Confirm charge completion",
1127
                    body: bodyPrefix + detail,
1128
                    threadIdentifier: session.id.uuidString
1129
                )
1130
            }
1131

            
1132
            return nil
1133
        }
1134
    }
1135

            
1136
    private func scheduleIfNeeded(_ payload: Payload) {
1137
        guard deliveredEventIDs().contains(payload.id) == false else {
1138
            return
1139
        }
1140

            
1141
        guard inFlightEventIDs.contains(payload.id) == false else {
1142
            return
1143
        }
1144

            
1145
        inFlightEventIDs.insert(payload.id)
1146

            
1147
        notificationCenter.getNotificationSettings { [weak self] settings in
1148
            guard let self else { return }
1149
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1150
                DispatchQueue.main.async {
1151
                    self.inFlightEventIDs.remove(payload.id)
1152
                }
1153
                return
1154
            }
1155

            
1156
            let content = UNMutableNotificationContent()
1157
            content.title = payload.title
1158
            content.body = payload.body
1159
            content.sound = .default
1160
            content.threadIdentifier = payload.threadIdentifier
1161

            
1162
            let request = UNNotificationRequest(
1163
                identifier: payload.id,
1164
                content: content,
1165
                trigger: nil
1166
            )
1167

            
1168
            self.notificationCenter.add(request) { error in
1169
                DispatchQueue.main.async {
1170
                    self.inFlightEventIDs.remove(payload.id)
1171
                    if let error {
1172
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1173
                        return
1174
                    }
1175
                    self.storeDeliveredEventID(payload.id)
1176
                }
1177
            }
1178
        }
1179
    }
1180

            
1181
    private func deliveredEventIDs() -> Set<String> {
1182
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1183
        return Set(values)
1184
    }
1185

            
1186
    private func storeDeliveredEventID(_ id: String) {
1187
        var values = deliveredEventIDs()
1188
        values.insert(id)
1189
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1190
    }
Bogdan Timofte authored 2 months ago
1191
}