Newer Older
1255 lines | 46.018kb
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 a month ago
46
    private var chargeInsightsReadStore: ChargeInsightsStore?
47
    private let chargedDevicesReloadQueue = DispatchQueue(
48
        label: "ro.xdev.usb-meter.charged-devices-reload",
49
        qos: .userInitiated
50
    )
51
    private var chargedDevicesReloadGeneration: UInt = 0
Bogdan Timofte authored 2 months ago
52
    private let meterStore = MeterNameStore.shared
Bogdan Timofte authored a month ago
53
    private var chargeInsightsStore: ChargeInsightsStore?
Bogdan Timofte authored a month ago
54
    private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
Bogdan Timofte authored a month ago
55
    private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
Bogdan Timofte authored 2 months ago
56

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

            
Bogdan Timofte authored 2 months ago
78
    let bluetoothManager = BluetoothManager()
Bogdan Timofte authored 2 months ago
79

            
Bogdan Timofte authored 2 months ago
80
    @Published var enableRecordFeature: Bool = true
Bogdan Timofte authored 2 months ago
81

            
Bogdan Timofte authored 2 months ago
82
    @Published var meters: [UUID:Meter] = [UUID:Meter]()
Bogdan Timofte authored a month ago
83
    @Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
Bogdan Timofte authored a month ago
84
    @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:]
Bogdan Timofte authored a month ago
85

            
86
    var deviceSummaries: [ChargedDeviceSummary] {
87
        chargedDevices.filter { !$0.isCharger }
88
    }
89

            
90
    var chargerSummaries: [ChargedDeviceSummary] {
91
        chargedDevices.filter { $0.isCharger }
92
    }
Bogdan Timofte authored 2 months ago
93

            
94
    var cloudAvailability: MeterNameStore.CloudAvailability {
95
        meterStore.currentCloudAvailability
96
    }
97

            
Bogdan Timofte authored a month ago
98
    func activateChargeInsights(context: NSManagedObjectContext) {
99
        guard chargeInsightsStore == nil else {
100
            return
101
        }
102

            
103
        context.automaticallyMergesChangesFromParent = true
Bogdan Timofte authored a month ago
104
        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
Bogdan Timofte authored a month ago
105
        chargeInsightsStore = ChargeInsightsStore(context: context)
106

            
Bogdan Timofte authored a month ago
107
        if let coordinator = context.persistentStoreCoordinator {
108
            let readContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
109
            readContext.persistentStoreCoordinator = coordinator
110
            readContext.automaticallyMergesChangesFromParent = true
111
            readContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
112
            chargeInsightsReadStore = ChargeInsightsStore(context: readContext)
113
        }
114

            
Bogdan Timofte authored a month ago
115
        chargeInsightsStoreObserver = NotificationCenter.default.publisher(
116
            for: .NSManagedObjectContextObjectsDidChange,
117
            object: context
118
        )
119
        .receive(on: DispatchQueue.main)
120
        .sink { [weak self] _ in
Bogdan Timofte authored a month ago
121
            self?.scheduleChargedDevicesReload()
Bogdan Timofte authored a month ago
122
        }
123

            
124
        chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
125
            for: .NSPersistentStoreRemoteChange,
126
            object: nil
127
        )
128
        .receive(on: DispatchQueue.main)
129
        .sink { [weak self] _ in
Bogdan Timofte authored a month ago
130
            self?.scheduleChargedDevicesReload()
Bogdan Timofte authored a month ago
131
        }
132

            
133
        chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
134
        reloadChargedDevices()
135
    }
136

            
Bogdan Timofte authored 2 months ago
137
    func meterName(for macAddress: String) -> String? {
138
        meterStore.name(for: macAddress)
139
    }
140

            
141
    func setMeterName(_ name: String, for macAddress: String) {
142
        meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
143
    }
144

            
145
    func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
146
        let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
147
        return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
148
    }
149

            
150
    func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
151
        meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
Bogdan Timofte authored 2 months ago
152
    }
Bogdan Timofte authored 2 months ago
153

            
Bogdan Timofte authored 2 months ago
154
    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
155
        meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
Bogdan Timofte authored 2 months ago
156
    }
157

            
158
    func noteMeterSeen(at date: Date, macAddress: String) {
159
        meterStore.noteLastSeen(date, for: macAddress)
160
    }
161

            
162
    func noteMeterConnected(at date: Date, macAddress: String) {
163
        meterStore.noteLastConnected(date, for: macAddress)
164
    }
165

            
166
    func lastSeen(for macAddress: String) -> Date? {
167
        meterStore.lastSeen(for: macAddress)
168
    }
169

            
170
    func lastConnected(for macAddress: String) -> Date? {
171
        meterStore.lastConnected(for: macAddress)
172
    }
173

            
Bogdan Timofte authored a month ago
174
    func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
175
        chargedDevices.first(where: { $0.id == id })
176
    }
177

            
Bogdan Timofte authored a month ago
178
    func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
179
        for chargedDevice in chargedDevices {
180
            if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
181
                return session
182
            }
183
        }
184
        return nil
185
    }
186

            
Bogdan Timofte authored a month ago
187
    func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
188
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
189
        return chargedDevices.filter { chargedDevice in
190
            guard chargedDevice.isCharger == false else {
191
                return false
192
            }
193
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
194
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
195
        }
196
    }
197

            
198
    func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] {
199
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
200
        return chargedDevices.filter { chargedDevice in
201
            guard chargedDevice.isCharger else {
202
                return false
203
            }
204
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
205
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
206
        }
207
    }
208

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

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

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

            
224
    func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
225
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
226

            
Bogdan Timofte authored a month ago
227
        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
Bogdan Timofte authored a month ago
228
           let chargerID = activeSession.chargerID,
229
           let liveCharger = chargedDevices.first(where: {
230
               $0.id == chargerID && $0.isCharger
231
           }) {
232
            return liveCharger
233
        }
234

            
235
        return chargedDevices.first(where: {
236
            $0.isCharger && $0.lastAssociatedMeterMAC == normalizedMAC
237
        })
238
    }
239

            
240
    func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
Bogdan Timofte authored a month ago
241
        if let cachedSummary = cachedActiveChargeSessionSummary(for: meterMACAddress) {
242
            return cachedSummary
243
        }
244
        return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
Bogdan Timofte authored a month ago
245
    }
246

            
Bogdan Timofte authored a month ago
247
    func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
248
        activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)]
249
    }
250

            
251
    @discardableResult
252
    func startChargerStandbyMeasurement(for chargerID: UUID, on meter: Meter) -> Bool {
253
        guard chargedDeviceSummary(id: chargerID)?.isCharger == true else {
254
            return false
255
        }
256

            
257
        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
258
        if let existingSession = activeChargerStandbySessions[normalizedMAC] {
259
            return existingSession.chargerID == chargerID
260
        }
261

            
262
        let session = ChargerStandbyPowerMonitorSession(chargerID: chargerID, meter: meter)
263
        session.onChange = { [weak self] in
264
            self?.scheduleObjectWillChange()
265
        }
266
        session.onStabilized = { [weak self, weak session] in
267
            guard let self, let session else { return }
268
            self.notifyChargerStandbyMeasurementReady(for: session)
269
        }
270

            
271
        activeChargerStandbySessions[normalizedMAC] = session
272
        session.start()
273

            
274
        // Starting a standby run on an available meter should also initiate the BLE link.
275
        if meter.operationalState == .peripheralNotConnected {
276
            meter.connect()
277
        }
278

            
279
        scheduleObjectWillChange()
280
        return true
281
    }
282

            
283
    @discardableResult
284
    func finishChargerStandbyMeasurement(for meterMACAddress: String, save: Bool) -> Bool {
285
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
286
        guard let session = activeChargerStandbySessions[normalizedMAC] else {
287
            return false
288
        }
289

            
290
        session.stop()
291

            
292
        guard save else {
293
            activeChargerStandbySessions[normalizedMAC] = nil
294
            scheduleObjectWillChange()
295
            return true
296
        }
297

            
298
        guard let summary = session.makeSummary() else {
299
            scheduleObjectWillChange()
300
            return false
301
        }
302

            
303
        let didSave = chargerStandbyPowerStore.save(summary)
304
        if didSave {
305
            activeChargerStandbySessions[normalizedMAC] = nil
306
            reloadChargedDevices()
307
        } else {
308
            scheduleObjectWillChange()
309
        }
310

            
311
        return didSave
312
    }
313

            
Bogdan Timofte authored a month ago
314
    @discardableResult
315
    func deleteChargerStandbyMeasurement(id: UUID, chargerID: UUID) -> Bool {
316
        let didDelete = chargerStandbyPowerStore.removeMeasurement(id: id, chargerID: chargerID)
317
        if didDelete {
318
            reloadChargedDevices()
319
        } else {
320
            scheduleObjectWillChange()
321
        }
322
        return didDelete
323
    }
324

            
Bogdan Timofte authored a month ago
325
    @discardableResult
Bogdan Timofte authored a month ago
326
    func createDevice(
Bogdan Timofte authored a month ago
327
        name: String,
328
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
329
        templateID: String?,
Bogdan Timofte authored a month ago
330
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
331
        supportsWiredCharging: Bool,
332
        supportsWirelessCharging: Bool,
333
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
334
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
335
        notes: String?,
336
        meterMACAddress: String?
337
    ) -> Bool {
Bogdan Timofte authored a month ago
338
        let didSave = chargeInsightsStore?.createDevice(
Bogdan Timofte authored a month ago
339
            name: name,
340
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
341
            templateID: templateID,
Bogdan Timofte authored a month ago
342
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
343
            supportsWiredCharging: supportsWiredCharging,
344
            supportsWirelessCharging: supportsWirelessCharging,
345
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
346
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
347
            notes: notes,
348
            assignTo: meterMACAddress
349
        ) ?? false
350

            
351
        if didSave {
352
            reloadChargedDevices()
353
        }
354

            
355
        return didSave
356
    }
357

            
358
    @discardableResult
Bogdan Timofte authored a month ago
359
    func createCharger(
360
        name: String,
Bogdan Timofte authored a month ago
361
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
362
        notes: String?,
363
        meterMACAddress: String?
364
    ) -> Bool {
365
        let didSave = chargeInsightsStore?.createCharger(
366
            name: name,
Bogdan Timofte authored a month ago
367
            chargerType: chargerType,
Bogdan Timofte authored a month ago
368
            notes: notes,
369
            assignTo: meterMACAddress
370
        ) ?? false
371

            
372
        if didSave {
373
            reloadChargedDevices()
374
        }
375

            
376
        return didSave
377
    }
378

            
379
    @discardableResult
380
    func updateDevice(
Bogdan Timofte authored a month ago
381
        id: UUID,
382
        name: String,
383
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
384
        templateID: String?,
Bogdan Timofte authored a month ago
385
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
386
        supportsWiredCharging: Bool,
387
        supportsWirelessCharging: Bool,
388
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
389
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
390
        notes: String?
391
    ) -> Bool {
Bogdan Timofte authored a month ago
392
        let didSave = chargeInsightsStore?.updateDevice(
Bogdan Timofte authored a month ago
393
            id: id,
394
            name: name,
395
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
396
            templateID: templateID,
Bogdan Timofte authored a month ago
397
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
398
            supportsWiredCharging: supportsWiredCharging,
399
            supportsWirelessCharging: supportsWirelessCharging,
400
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
401
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
402
            notes: notes
403
        ) ?? false
404

            
405
        if didSave {
406
            reloadChargedDevices()
407
        }
408

            
409
        return didSave
410
    }
411

            
412
    @discardableResult
Bogdan Timofte authored a month ago
413
    func updateCharger(
414
        id: UUID,
415
        name: String,
Bogdan Timofte authored a month ago
416
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
417
        notes: String?
418
    ) -> Bool {
419
        let didSave = chargeInsightsStore?.updateCharger(
420
            id: id,
421
            name: name,
Bogdan Timofte authored a month ago
422
            chargerType: chargerType,
Bogdan Timofte authored a month ago
423
            notes: notes
Bogdan Timofte authored a month ago
424
        ) ?? false
425

            
426
        if didSave {
427
            reloadChargedDevices()
428
        }
429

            
430
        return didSave
431
    }
432

            
433
    @discardableResult
434
    func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool {
435
        let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false
436
        if didSave {
437
            reloadChargedDevices()
438
        }
439
        return didSave
440
    }
441

            
442
    @discardableResult
443
    func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
444
        let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false
445
        if didSave {
446
            reloadChargedDevices()
447
        }
448
        return didSave
449
    }
450

            
Bogdan Timofte authored a month ago
451
    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
452
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
453
            return
454
        }
455
        guard activeSession.status == .active else {
456
            return
457
        }
458
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
459
    }
460

            
Bogdan Timofte authored a month ago
461
    @discardableResult
Bogdan Timofte authored a month ago
462
    func startChargeSession(
463
        for meter: Meter,
464
        chargedDeviceID: UUID,
465
        chargerID: UUID?,
466
        chargingTransportMode: ChargingTransportMode,
467
        chargingStateMode: ChargingStateMode,
468
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
469
        initialBatteryPercent: Double?,
470
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
471
    ) -> Bool {
Bogdan Timofte authored a month ago
472
        meter.resetMeterCountersForNewSession()
473

            
Bogdan Timofte authored a month ago
474
        guard let snapshot = meter.chargingMonitorSnapshot else {
475
            return false
476
        }
477

            
Bogdan Timofte authored a month ago
478
        let didSave = chargeInsightsStore?.startSession(
479
            for: snapshot,
480
            chargedDeviceID: chargedDeviceID,
481
            chargerID: chargerID,
482
            chargingTransportMode: chargingTransportMode,
483
            chargingStateMode: chargingStateMode,
484
            autoStopEnabled: autoStopEnabled,
Bogdan Timofte authored a month ago
485
            initialBatteryPercent: initialBatteryPercent,
486
            startsFromFlatBattery: startsFromFlatBattery
Bogdan Timofte authored a month ago
487
        ) ?? false
488
        if didSave {
489
            reloadChargedDevices()
Bogdan Timofte authored a month ago
490
            meter.resetChargeRecordGraph()
491
            if let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description),
492
               meter.supportsRecordingThreshold,
493
               activeSession.stopThresholdAmps > 0 {
494
                meter.recordingTreshold = activeSession.stopThresholdAmps
495
            }
496
            restoreChargeMonitoringStateIfNeeded(for: meter)
Bogdan Timofte authored a month ago
497
        }
498
        return didSave
499
    }
500

            
501
    @discardableResult
502
    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
503
        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
504
        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
505
        if didSave {
506
            reloadChargedDevices()
507
        }
508
        return didSave
509
    }
510

            
511
    @discardableResult
512
    func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
513
        let snapshot = meter?.chargingMonitorSnapshot
514
        let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
515
        if didSave {
516
            reloadChargedDevices()
517
        }
518
        return didSave
519
    }
520

            
521
    @discardableResult
Bogdan Timofte authored a month ago
522
    func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil) -> Bool {
Bogdan Timofte authored a month ago
523
        let didSave = chargeInsightsStore?.stopSession(
524
            id: sessionID,
Bogdan Timofte authored a month ago
525
            finalBatteryPercent: finalBatteryPercent
Bogdan Timofte authored a month ago
526
        ) ?? false
Bogdan Timofte authored a month ago
527
        reloadChargedDevices()
Bogdan Timofte authored a month ago
528
        return didSave
529
    }
530

            
531
    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
532
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
533
            return
534
        }
535

            
536
        if chargeInsightsStore?.observe(snapshot: snapshot) == true {
537
            reloadChargedDevices()
538
        }
539
    }
540

            
541
    @discardableResult
Bogdan Timofte authored a month ago
542
    func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
Bogdan Timofte authored a month ago
543
        observeChargeSnapshot(from: meter)
544

            
545
        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
546
        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
547
        let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
548

            
Bogdan Timofte authored a month ago
549
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
550
            percent: percent,
Bogdan Timofte authored a month ago
551
            for: meter.btSerial.macAddress.description,
552
            measuredEnergyWh: checkpointEnergyWh,
553
            measuredChargeAh: checkpointChargeAh
Bogdan Timofte authored a month ago
554
        ) ?? false
555

            
556
        if didSave {
557
            reloadChargedDevices()
558
        }
559

            
560
        return didSave
561
    }
562

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

            
Bogdan Timofte authored a month ago
569
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
570
            percent: percent,
571
            for: sessionID
572
        ) ?? false
573

            
574
        if didSave {
575
            reloadChargedDevices()
576
        }
577

            
578
        return didSave
579
    }
580

            
Bogdan Timofte authored a month ago
581
    @discardableResult
582
    func addBatteryCheckpoint(
583
        percent: Double,
584
        for sessionID: UUID,
585
        measuredEnergyWh: Double?,
586
        measuredChargeAh: Double?
587
    ) -> Bool {
Bogdan Timofte authored a month ago
588
        guard canAddBatteryCheckpoint(to: sessionID) else {
589
            return false
590
        }
591

            
Bogdan Timofte authored a month ago
592
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
593
            percent: percent,
594
            for: sessionID,
595
            measuredEnergyWh: measuredEnergyWh,
596
            measuredChargeAh: measuredChargeAh
597
        ) ?? false
598

            
599
        if didSave {
600
            reloadChargedDevices()
601
        }
602

            
603
        return didSave
604
    }
605

            
Bogdan Timofte authored a month ago
606
    func canAddBatteryCheckpoint(to sessionID: UUID) -> Bool {
607
        guard let session = chargeSessionSummary(id: sessionID),
608
              session.status.isOpen,
609
              let meterMACAddress = session.meterMACAddress else {
610
            return false
611
        }
612

            
613
        return meter(for: meterMACAddress) != nil
614
    }
615

            
616
    func batteryCheckpointCaptureRequirementMessage(for sessionID: UUID) -> String? {
617
        guard let session = chargeSessionSummary(id: sessionID) else {
618
            return "Battery checkpoints are available only while the charge session is still active."
619
        }
620

            
621
        guard session.status.isOpen else {
622
            return "Battery checkpoints are available only while the charge session is still active."
623
        }
624

            
625
        guard let meterMACAddress = session.meterMACAddress,
626
              meter(for: meterMACAddress) != nil else {
627
            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."
628
        }
629

            
630
        return nil
631
    }
632

            
Bogdan Timofte authored a month ago
633
    func batteryCheckpointPlausibilityWarning(
634
        percent: Double,
Bogdan Timofte authored a month ago
635
        for sessionID: UUID,
636
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
637
    ) -> BatteryCheckpointPlausibilityWarning? {
638
        guard let session = chargeSessionSummary(id: sessionID) else {
639
            return nil
640
        }
Bogdan Timofte authored a month ago
641
        return batteryCheckpointPlausibilityWarning(
642
            percent: percent,
643
            for: session,
644
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
645
        )
Bogdan Timofte authored a month ago
646
    }
647

            
648
    @discardableResult
649
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
650
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
651
            id: checkpointID,
652
            from: sessionID
653
        ) ?? false
654

            
655
        if didDelete {
Bogdan Timofte authored a month ago
656
            reloadChargedDevices()
Bogdan Timofte authored a month ago
657
        }
658

            
659
        return didDelete
660
    }
661

            
Bogdan Timofte authored a month ago
662
    @discardableResult
663
    func flushChargeInsights() -> Bool {
664
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
665
        reloadChargedDevices()
666
        return didSave
667
    }
668

            
669
    @discardableResult
670
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
671
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
672
            return false
673
        }
674
        return setTargetBatteryPercent(percent, for: activeSession.id)
675
    }
676

            
677
    @discardableResult
678
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
679
        if percent != nil {
680
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
681
        }
682

            
683
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
684
        if didSave {
685
            reloadChargedDevices()
686
        }
687
        return didSave
688
    }
689

            
690
    @discardableResult
691
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
692
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
693
        if didSave {
694
            reloadChargedDevices()
695
        }
696
        return didSave
697
    }
698

            
699
    @discardableResult
700
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
701
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
702
        if didSave {
703
            reloadChargedDevices()
704
        }
705
        return didSave
706
    }
707

            
708
    @discardableResult
709
    func deleteChargeSession(sessionID: UUID) -> Bool {
710
        let deletedSession = chargedDevices
711
            .flatMap(\.sessions)
712
            .first(where: { $0.id == sessionID })
713

            
714
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
715
        guard didDelete else {
716
            return false
717
        }
718

            
Bogdan Timofte authored a month ago
719
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
720
           let meterMACAddress = deletedSession?.meterMACAddress,
721
           let liveMeter = meter(for: meterMACAddress) {
722
            liveMeter.resetChargeRecord()
723
        }
724

            
725
        reloadChargedDevices()
726
        return true
727
    }
728

            
729
    @discardableResult
730
    func deleteChargedDevice(id: UUID) -> Bool {
731
        let deletedDevice = chargedDeviceSummary(id: id)
732
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
733
        guard didDelete else {
734
            return false
735
        }
736

            
Bogdan Timofte authored a month ago
737
        if deletedDevice?.isCharger == true {
738
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
739
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
740
                session.stop()
741
                activeChargerStandbySessions[meterMACAddress] = nil
742
            }
743
        }
744

            
Bogdan Timofte authored a month ago
745
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
746
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
747
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
748
           let liveMeter = meter(for: meterMACAddress) {
749
            liveMeter.resetChargeRecord()
750
        }
751

            
752
        reloadChargedDevices()
753
        return true
754
    }
755

            
756
    @discardableResult
757
    func createKnownMeter(
758
        macAddress: String,
759
        customName: String?,
760
        modelName: String,
761
        advertisedName: String?
762
    ) -> Bool {
763
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
764
        guard Self.isValidMACAddress(normalizedMAC) else {
765
            return false
766
        }
767

            
768
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
769
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
770
            setMeterName(customName, for: normalizedMAC)
771
        }
772
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
773
        return true
774
    }
775

            
776
    @discardableResult
777
    func deleteMeter(macAddress: String) -> Bool {
778
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
779
        guard Self.isValidMACAddress(normalizedMAC) else {
780
            return false
781
        }
782

            
783
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
784
            meter.disconnect()
785
        }
786
        meters = meters.filter { element in
787
            element.value.btSerial.macAddress.description != normalizedMAC
788
        }
789

            
790
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
791
        if didDelete {
792
            scheduleObjectWillChange()
793
        }
794
        return didDelete
795
    }
796

            
Bogdan Timofte authored 2 months ago
797
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored 2 months ago
798
        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
799
        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
800
        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
801

            
802
        return macAddresses.map { macAddress in
803
            let liveMeter = liveMetersByMAC[macAddress]
804
            let record = recordsByMAC[macAddress]
805

            
Bogdan Timofte authored 2 months ago
806
            return MeterSummary(
Bogdan Timofte authored 2 months ago
807
                macAddress: macAddress,
Bogdan Timofte authored 2 months ago
808
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
Bogdan Timofte authored 2 months ago
809
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
Bogdan Timofte authored 2 months ago
810
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
811
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
812
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
813
                meter: liveMeter
814
            )
815
        }
816
        .sorted { lhs, rhs in
Bogdan Timofte authored 2 months ago
817
            if lhs.meter != nil && rhs.meter == nil {
818
                return true
819
            }
820
            if lhs.meter == nil && rhs.meter != nil {
821
                return false
822
            }
Bogdan Timofte authored 2 months ago
823
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
824
            if byName != .orderedSame {
825
                return byName == .orderedAscending
826
            }
827
            return lhs.macAddress < rhs.macAddress
828
        }
829
    }
830

            
Bogdan Timofte authored 2 months ago
831
    private func scheduleObjectWillChange() {
832
        DispatchQueue.main.async { [weak self] in
833
            self?.objectWillChange.send()
834
        }
835
    }
Bogdan Timofte authored 2 months ago
836

            
Bogdan Timofte authored a month ago
837
    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
838
        pendingChargedDevicesReloadWorkItem?.cancel()
839

            
840
        let workItem = DispatchWorkItem { [weak self] in
841
            self?.reloadChargedDevices()
842
        }
843
        pendingChargedDevicesReloadWorkItem = workItem
844
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
845
    }
846

            
847
    private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
848
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
849
        guard !normalizedMAC.isEmpty else {
850
            return nil
851
        }
852

            
853
        return chargedDevices
854
            .lazy
855
            .compactMap(\.activeSession)
Bogdan Timofte authored a month ago
856
            .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
857
    }
858

            
Bogdan Timofte authored a month ago
859
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
860
        pendingChargedDevicesReloadWorkItem?.cancel()
861
        pendingChargedDevicesReloadWorkItem = nil
862

            
Bogdan Timofte authored a month ago
863
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
Bogdan Timofte authored a month ago
864
        let reloadGeneration = chargedDevicesReloadGeneration &+ 1
865
        chargedDevicesReloadGeneration = reloadGeneration
866
        let readStore = chargeInsightsReadStore ?? chargeInsightsStore
867

            
868
        chargedDevicesReloadQueue.async { [weak self] in
869
            guard let self else { return }
870

            
871
            readStore?.resetContext()
872
            let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
873
                chargedDevice.withStandbyPowerMeasurements(
874
                    standbyMeasurementsByChargerID[chargedDevice.id] ?? []
875
                )
876
            }
877

            
878
            DispatchQueue.main.async { [weak self] in
879
                guard let self else { return }
880
                guard reloadGeneration == self.chargedDevicesReloadGeneration else {
881
                    return
882
                }
883

            
884
                self.chargedDevices = summaries
885
                self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
886
                for meter in self.meters.values {
887
                    self.restoreChargeMonitoringStateIfNeeded(for: meter)
888
                }
889
            }
Bogdan Timofte authored a month ago
890
        }
891
    }
892

            
893
    private func meter(for meterMACAddress: String) -> Meter? {
894
        meters.values.first { meter in
895
            meter.btSerial.macAddress.description == meterMACAddress
896
        }
897
    }
898

            
Bogdan Timofte authored 2 months ago
899
    private func refreshMeterMetadata() {
900
        DispatchQueue.main.async { [weak self] in
901
            guard let self else { return }
902
            var didUpdateAnyMeter = false
903
            for meter in self.meters.values {
904
                let mac = meter.btSerial.macAddress.description
905
                let displayName = self.meterName(for: mac) ?? mac
906
                if meter.name != displayName {
907
                    meter.updateNameFromStore(displayName)
908
                    didUpdateAnyMeter = true
909
                }
910

            
911
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
912
                meter.reloadTemperatureUnitPreference()
913
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
914
                    didUpdateAnyMeter = true
915
                }
916
            }
917

            
918
            if didUpdateAnyMeter {
919
                self.scheduleObjectWillChange()
920
            }
921
        }
922
    }
Bogdan Timofte authored a month ago
923

            
Bogdan Timofte authored a month ago
924
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
925
        guard let charger = chargedDeviceSummary(id: session.chargerID),
926
              let statistics = session.statistics else {
927
            return
928
        }
929

            
930
        let content = UNMutableNotificationContent()
931
        content.title = "Standby baseline stabilised"
932
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
933
        content.sound = .default
934
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
935

            
936
        let request = UNNotificationRequest(
937
            identifier: "charger-standby-\(session.id.uuidString)",
938
            content: content,
939
            trigger: nil
940
        )
941
        UNUserNotificationCenter.current().add(request)
942
        scheduleObjectWillChange()
943
    }
944

            
Bogdan Timofte authored a month ago
945
    private func batteryCheckpointPlausibilityWarning(
946
        percent: Double,
Bogdan Timofte authored a month ago
947
        for session: ChargeSessionSummary,
948
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
949
    ) -> BatteryCheckpointPlausibilityWarning? {
950
        guard percent.isFinite, percent >= 0, percent <= 100 else {
951
            return nil
952
        }
953

            
954
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
955
            if lhs.timestamp != rhs.timestamp {
956
                return lhs.timestamp < rhs.timestamp
957
            }
958
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
959
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
960
            }
961
            return lhs.id.uuidString < rhs.id.uuidString
962
        }
963

            
964
        if let lastCheckpoint = sortedCheckpoints.last,
965
           percent < lastCheckpoint.batteryPercent - 1.5 {
966
            return BatteryCheckpointPlausibilityWarning(
967
                title: "Checkpoint Goes Backwards",
968
                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."
969
            )
970
        }
971

            
Bogdan Timofte authored a month ago
972
        let effectiveEnergyWh = effectiveEnergyWhOverride
973
            ?? session.effectiveBatteryEnergyWh
974
            ?? session.measuredEnergyWh
975

            
976
        if let lastCheckpoint = sortedCheckpoints.last,
977
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
978
            let estimatedCapacityWh = session.capacityEstimateWh
979
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
980
                ?? chargedDevice.estimatedBatteryCapacityWh
981

            
982
            if let estimatedCapacityWh, estimatedCapacityWh > 0 {
983
                let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
984
                let expectedPercent = min(
985
                    100,
986
                    max(
987
                        lastCheckpoint.batteryPercent,
988
                        lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
989
                    )
990
                )
991
                let predictionGap = percent - expectedPercent
992
                guard abs(predictionGap) >= 4 else {
993
                    return nil
994
                }
995

            
996
                let direction = predictionGap > 0 ? "above" : "below"
997
                let gapText = abs(predictionGap).format(decimalDigits: 0)
998
                let expectedText = expectedPercent.format(decimalDigits: 0)
999

            
1000
                return BatteryCheckpointPlausibilityWarning(
1001
                    title: "Checkpoint Looks Implausible",
1002
                    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."
1003
                )
1004
            }
1005
        }
1006

            
Bogdan Timofte authored a month ago
1007
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
Bogdan Timofte authored a month ago
1008
              let prediction = chargedDevice.batteryLevelPrediction(
1009
                for: session,
1010
                effectiveEnergyWhOverride: effectiveEnergyWh
1011
              )
Bogdan Timofte authored a month ago
1012
        else {
1013
            return nil
1014
        }
1015

            
1016
        let predictionGap = percent - prediction.predictedPercent
1017
        guard abs(predictionGap) >= 4 else {
1018
            return nil
1019
        }
1020

            
1021
        let direction = predictionGap > 0 ? "above" : "below"
1022
        let gapText = abs(predictionGap).format(decimalDigits: 0)
1023
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
1024

            
1025
        if let lastCheckpoint = sortedCheckpoints.last {
1026
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1027
            return BatteryCheckpointPlausibilityWarning(
1028
                title: "Checkpoint Looks Implausible",
1029
                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."
1030
            )
1031
        }
1032

            
1033
        return BatteryCheckpointPlausibilityWarning(
1034
            title: "Checkpoint Looks Implausible",
1035
            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."
1036
        )
1037
    }
Bogdan Timofte authored a month ago
1038

            
1039
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1040
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1041
        guard session.status.isOpen else {
1042
            return storedEnergyWh
1043
        }
1044

            
1045
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1046
            return storedEnergyWh
1047
        }
1048

            
1049
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1050
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
1051
        }
1052

            
1053
        return storedEnergyWh
1054
    }
1055

            
1056
    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1057
        let storedChargeAh = session.measuredChargeAh
1058
        guard session.status.isOpen else {
1059
            return storedChargeAh
1060
        }
1061

            
1062
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1063
            return storedChargeAh
1064
        }
1065

            
1066
        if let baselineChargeAh = session.meterChargeBaselineAh {
1067
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
1068
        }
1069

            
1070
        return storedChargeAh
1071
    }
Bogdan Timofte authored 2 months ago
1072
}
Bogdan Timofte authored 2 months ago
1073

            
1074
extension AppData.MeterSummary {
1075
    var tint: Color {
1076
        switch modelSummary {
1077
        case "UM25C":
1078
            return .blue
1079
        case "UM34C":
1080
            return .yellow
1081
        case "TC66C":
1082
            return Model.TC66C.color
1083
        default:
1084
            return .secondary
1085
        }
1086
    }
1087
}
Bogdan Timofte authored 2 months ago
1088

            
Bogdan Timofte authored a month ago
1089
extension AppData {
Bogdan Timofte authored 2 months ago
1090
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
1091
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
1092
            return liveName
1093
        }
1094
        if let customName = record?.customName {
1095
            return customName
1096
        }
1097
        if let advertisedName = record?.advertisedName {
1098
            return advertisedName
1099
        }
1100
        if let recordModel = record?.modelName {
1101
            return recordModel
1102
        }
1103
        if let liveModel = liveMeter?.deviceModelSummary {
1104
            return liveModel
1105
        }
1106
        return "Meter"
1107
    }
Bogdan Timofte authored a month ago
1108

            
1109
    static func normalizedMACAddress(_ macAddress: String) -> String {
1110
        macAddress
1111
            .trimmingCharacters(in: .whitespacesAndNewlines)
1112
            .uppercased()
1113
    }
1114

            
1115
    static func isValidMACAddress(_ macAddress: String) -> Bool {
1116
        macAddress.range(
1117
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
1118
            options: .regularExpression
1119
        ) != nil
1120
    }
1121
}
1122

            
1123
private final class ChargeNotificationCoordinator {
1124
    private struct Payload {
1125
        let id: String
1126
        let title: String
1127
        let body: String
1128
        let threadIdentifier: String
1129
    }
1130

            
1131
    private let notificationCenter = UNUserNotificationCenter.current()
1132
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
1133
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
1134
    private var inFlightEventIDs: Set<String> = []
1135

            
1136
    func ensureAuthorizationIfNeeded() {
1137
        notificationCenter.getNotificationSettings { [weak self] settings in
1138
            guard settings.authorizationStatus == .notDetermined else {
1139
                return
1140
            }
1141

            
1142
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
1143
                if let error {
1144
                    track("Notification authorization request failed: \(error.localizedDescription)")
1145
                }
1146
            }
1147
        }
1148
    }
1149

            
1150
    func process(chargedDevices: [ChargedDeviceSummary]) {
1151
        let now = Date()
1152
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
1153
            payloads(for: chargedDevice, now: now)
1154
        }
1155

            
1156
        for payload in pendingPayloads {
1157
            scheduleIfNeeded(payload)
1158
        }
1159
    }
1160

            
1161
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1162
        chargedDevice.sessions.compactMap { session in
1163
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1164
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1165
               let targetBatteryPercent = session.targetBatteryPercent {
1166
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1167
                    ?? session.endBatteryPercent
1168
                    ?? targetBatteryPercent
1169

            
1170
                return Payload(
1171
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1172
                    title: "Battery target reached",
1173
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1174
                    threadIdentifier: session.id.uuidString
1175
                )
1176
            }
1177

            
1178
            if session.requiresCompletionConfirmation,
1179
               let requestedAt = session.completionConfirmationRequestedAt,
1180
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
1181
                let estimatedPercent = session.completionContradictionPercent
1182
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1183
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
1184
                let detail = estimatedPercent.map {
1185
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
1186
                } ?? ""
1187

            
1188
                return Payload(
1189
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1190
                    title: "Confirm charge completion",
1191
                    body: bodyPrefix + detail,
1192
                    threadIdentifier: session.id.uuidString
1193
                )
1194
            }
1195

            
1196
            return nil
1197
        }
1198
    }
1199

            
1200
    private func scheduleIfNeeded(_ payload: Payload) {
1201
        guard deliveredEventIDs().contains(payload.id) == false else {
1202
            return
1203
        }
1204

            
1205
        guard inFlightEventIDs.contains(payload.id) == false else {
1206
            return
1207
        }
1208

            
1209
        inFlightEventIDs.insert(payload.id)
1210

            
1211
        notificationCenter.getNotificationSettings { [weak self] settings in
1212
            guard let self else { return }
1213
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1214
                DispatchQueue.main.async {
1215
                    self.inFlightEventIDs.remove(payload.id)
1216
                }
1217
                return
1218
            }
1219

            
1220
            let content = UNMutableNotificationContent()
1221
            content.title = payload.title
1222
            content.body = payload.body
1223
            content.sound = .default
1224
            content.threadIdentifier = payload.threadIdentifier
1225

            
1226
            let request = UNNotificationRequest(
1227
                identifier: payload.id,
1228
                content: content,
1229
                trigger: nil
1230
            )
1231

            
1232
            self.notificationCenter.add(request) { error in
1233
                DispatchQueue.main.async {
1234
                    self.inFlightEventIDs.remove(payload.id)
1235
                    if let error {
1236
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1237
                        return
1238
                    }
1239
                    self.storeDeliveredEventID(payload.id)
1240
                }
1241
            }
1242
        }
1243
    }
1244

            
1245
    private func deliveredEventIDs() -> Set<String> {
1246
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1247
        return Set(values)
1248
    }
1249

            
1250
    private func storeDeliveredEventID(_ id: String) {
1251
        var values = deliveredEventIDs()
1252
        values.insert(id)
1253
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1254
    }
Bogdan Timofte authored 2 months ago
1255
}