Newer Older
1270 lines | 46.399kb
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
    )
Bogdan Timofte authored a month ago
51
    private var chargedDevicesReloadInFlight = false
52
    private var chargedDevicesReloadPending = false
Bogdan Timofte authored 2 months ago
53
    private let meterStore = MeterNameStore.shared
Bogdan Timofte authored a month ago
54
    private var chargeInsightsStore: ChargeInsightsStore?
Bogdan Timofte authored a month ago
55
    private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
Bogdan Timofte authored a month ago
56
    private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
Bogdan Timofte authored 2 months ago
57

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
210
    func currentChargedDeviceSummary(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 liveDevice = chargedDevices.first(where: {
215
               $0.id == activeSession.chargedDeviceID && $0.isCharger == false
216
           }) {
217
            return liveDevice
218
        }
219

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

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

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

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

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

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

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

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

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

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

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

            
280
        scheduleObjectWillChange()
281
        return true
282
    }
283

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

            
291
        session.stop()
292

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

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

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

            
312
        return didSave
313
    }
314

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

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

            
352
        if didSave {
353
            reloadChargedDevices()
354
        }
355

            
356
        return didSave
357
    }
358

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

            
373
        if didSave {
374
            reloadChargedDevices()
375
        }
376

            
377
        return didSave
378
    }
379

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

            
406
        if didSave {
407
            reloadChargedDevices()
408
        }
409

            
410
        return didSave
411
    }
412

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

            
427
        if didSave {
428
            reloadChargedDevices()
429
        }
430

            
431
        return didSave
432
    }
433

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
557
        if didSave {
558
            reloadChargedDevices()
559
        }
560

            
561
        return didSave
562
    }
563

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

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

            
575
        if didSave {
576
            reloadChargedDevices()
577
        }
578

            
579
        return didSave
580
    }
581

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

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

            
600
        if didSave {
601
            reloadChargedDevices()
602
        }
603

            
604
        return didSave
605
    }
606

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

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

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

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

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

            
631
        return nil
632
    }
633

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

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

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

            
660
        return didDelete
661
    }
662

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

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

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

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

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

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

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

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

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

            
726
        reloadChargedDevices()
727
        return true
728
    }
729

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

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

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

            
753
        reloadChargedDevices()
754
        return true
755
    }
756

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
860
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
861
        if Thread.isMainThread == false {
862
            DispatchQueue.main.async { [weak self] in
863
                self?.reloadChargedDevices()
864
            }
865
            return
866
        }
867

            
Bogdan Timofte authored a month ago
868
        pendingChargedDevicesReloadWorkItem?.cancel()
869
        pendingChargedDevicesReloadWorkItem = nil
870

            
Bogdan Timofte authored a month ago
871
        guard chargedDevicesReloadInFlight == false else {
872
            chargedDevicesReloadPending = true
873
            return
874
        }
875

            
Bogdan Timofte authored a month ago
876
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
Bogdan Timofte authored a month ago
877
        let readStore = chargeInsightsReadStore ?? chargeInsightsStore
Bogdan Timofte authored a month ago
878
        chargedDevicesReloadInFlight = true
879
        chargedDevicesReloadPending = false
Bogdan Timofte authored a month ago
880

            
881
        chargedDevicesReloadQueue.async { [weak self] in
882
            guard let self else { return }
883

            
884
            readStore?.resetContext()
885
            let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
886
                chargedDevice.withStandbyPowerMeasurements(
887
                    standbyMeasurementsByChargerID[chargedDevice.id] ?? []
888
                )
889
            }
890

            
891
            DispatchQueue.main.async { [weak self] in
892
                guard let self else { return }
893

            
894
                self.chargedDevices = summaries
895
                self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
896
                for meter in self.meters.values {
897
                    self.restoreChargeMonitoringStateIfNeeded(for: meter)
898
                }
Bogdan Timofte authored a month ago
899

            
900
                self.chargedDevicesReloadInFlight = false
901
                if self.chargedDevicesReloadPending {
902
                    self.reloadChargedDevices()
903
                }
Bogdan Timofte authored a month ago
904
            }
Bogdan Timofte authored a month ago
905
        }
906
    }
907

            
908
    private func meter(for meterMACAddress: String) -> Meter? {
909
        meters.values.first { meter in
910
            meter.btSerial.macAddress.description == meterMACAddress
911
        }
912
    }
913

            
Bogdan Timofte authored 2 months ago
914
    private func refreshMeterMetadata() {
915
        DispatchQueue.main.async { [weak self] in
916
            guard let self else { return }
917
            var didUpdateAnyMeter = false
918
            for meter in self.meters.values {
919
                let mac = meter.btSerial.macAddress.description
920
                let displayName = self.meterName(for: mac) ?? mac
921
                if meter.name != displayName {
922
                    meter.updateNameFromStore(displayName)
923
                    didUpdateAnyMeter = true
924
                }
925

            
926
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
927
                meter.reloadTemperatureUnitPreference()
928
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
929
                    didUpdateAnyMeter = true
930
                }
931
            }
932

            
933
            if didUpdateAnyMeter {
934
                self.scheduleObjectWillChange()
935
            }
936
        }
937
    }
Bogdan Timofte authored a month ago
938

            
Bogdan Timofte authored a month ago
939
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
940
        guard let charger = chargedDeviceSummary(id: session.chargerID),
941
              let statistics = session.statistics else {
942
            return
943
        }
944

            
945
        let content = UNMutableNotificationContent()
946
        content.title = "Standby baseline stabilised"
947
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
948
        content.sound = .default
949
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
950

            
951
        let request = UNNotificationRequest(
952
            identifier: "charger-standby-\(session.id.uuidString)",
953
            content: content,
954
            trigger: nil
955
        )
956
        UNUserNotificationCenter.current().add(request)
957
        scheduleObjectWillChange()
958
    }
959

            
Bogdan Timofte authored a month ago
960
    private func batteryCheckpointPlausibilityWarning(
961
        percent: Double,
Bogdan Timofte authored a month ago
962
        for session: ChargeSessionSummary,
963
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
964
    ) -> BatteryCheckpointPlausibilityWarning? {
965
        guard percent.isFinite, percent >= 0, percent <= 100 else {
966
            return nil
967
        }
968

            
969
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
970
            if lhs.timestamp != rhs.timestamp {
971
                return lhs.timestamp < rhs.timestamp
972
            }
973
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
974
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
975
            }
976
            return lhs.id.uuidString < rhs.id.uuidString
977
        }
978

            
979
        if let lastCheckpoint = sortedCheckpoints.last,
980
           percent < lastCheckpoint.batteryPercent - 1.5 {
981
            return BatteryCheckpointPlausibilityWarning(
982
                title: "Checkpoint Goes Backwards",
983
                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."
984
            )
985
        }
986

            
Bogdan Timofte authored a month ago
987
        let effectiveEnergyWh = effectiveEnergyWhOverride
988
            ?? session.effectiveBatteryEnergyWh
989
            ?? session.measuredEnergyWh
990

            
991
        if let lastCheckpoint = sortedCheckpoints.last,
992
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
993
            let estimatedCapacityWh = session.capacityEstimateWh
994
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
995
                ?? chargedDevice.estimatedBatteryCapacityWh
996

            
997
            if let estimatedCapacityWh, estimatedCapacityWh > 0 {
998
                let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
999
                let expectedPercent = min(
1000
                    100,
1001
                    max(
1002
                        lastCheckpoint.batteryPercent,
1003
                        lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
1004
                    )
1005
                )
1006
                let predictionGap = percent - expectedPercent
1007
                guard abs(predictionGap) >= 4 else {
1008
                    return nil
1009
                }
1010

            
1011
                let direction = predictionGap > 0 ? "above" : "below"
1012
                let gapText = abs(predictionGap).format(decimalDigits: 0)
1013
                let expectedText = expectedPercent.format(decimalDigits: 0)
1014

            
1015
                return BatteryCheckpointPlausibilityWarning(
1016
                    title: "Checkpoint Looks Implausible",
1017
                    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."
1018
                )
1019
            }
1020
        }
1021

            
Bogdan Timofte authored a month ago
1022
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
Bogdan Timofte authored a month ago
1023
              let prediction = chargedDevice.batteryLevelPrediction(
1024
                for: session,
1025
                effectiveEnergyWhOverride: effectiveEnergyWh
1026
              )
Bogdan Timofte authored a month ago
1027
        else {
1028
            return nil
1029
        }
1030

            
1031
        let predictionGap = percent - prediction.predictedPercent
1032
        guard abs(predictionGap) >= 4 else {
1033
            return nil
1034
        }
1035

            
1036
        let direction = predictionGap > 0 ? "above" : "below"
1037
        let gapText = abs(predictionGap).format(decimalDigits: 0)
1038
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
1039

            
1040
        if let lastCheckpoint = sortedCheckpoints.last {
1041
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1042
            return BatteryCheckpointPlausibilityWarning(
1043
                title: "Checkpoint Looks Implausible",
1044
                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."
1045
            )
1046
        }
1047

            
1048
        return BatteryCheckpointPlausibilityWarning(
1049
            title: "Checkpoint Looks Implausible",
1050
            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."
1051
        )
1052
    }
Bogdan Timofte authored a month ago
1053

            
1054
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1055
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1056
        guard session.status.isOpen else {
1057
            return storedEnergyWh
1058
        }
1059

            
1060
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1061
            return storedEnergyWh
1062
        }
1063

            
1064
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1065
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
1066
        }
1067

            
1068
        return storedEnergyWh
1069
    }
1070

            
1071
    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1072
        let storedChargeAh = session.measuredChargeAh
1073
        guard session.status.isOpen else {
1074
            return storedChargeAh
1075
        }
1076

            
1077
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1078
            return storedChargeAh
1079
        }
1080

            
1081
        if let baselineChargeAh = session.meterChargeBaselineAh {
1082
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
1083
        }
1084

            
1085
        return storedChargeAh
1086
    }
Bogdan Timofte authored 2 months ago
1087
}
Bogdan Timofte authored 2 months ago
1088

            
1089
extension AppData.MeterSummary {
1090
    var tint: Color {
1091
        switch modelSummary {
1092
        case "UM25C":
1093
            return .blue
1094
        case "UM34C":
1095
            return .yellow
1096
        case "TC66C":
1097
            return Model.TC66C.color
1098
        default:
1099
            return .secondary
1100
        }
1101
    }
1102
}
Bogdan Timofte authored 2 months ago
1103

            
Bogdan Timofte authored a month ago
1104
extension AppData {
Bogdan Timofte authored 2 months ago
1105
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
1106
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
1107
            return liveName
1108
        }
1109
        if let customName = record?.customName {
1110
            return customName
1111
        }
1112
        if let advertisedName = record?.advertisedName {
1113
            return advertisedName
1114
        }
1115
        if let recordModel = record?.modelName {
1116
            return recordModel
1117
        }
1118
        if let liveModel = liveMeter?.deviceModelSummary {
1119
            return liveModel
1120
        }
1121
        return "Meter"
1122
    }
Bogdan Timofte authored a month ago
1123

            
1124
    static func normalizedMACAddress(_ macAddress: String) -> String {
1125
        macAddress
1126
            .trimmingCharacters(in: .whitespacesAndNewlines)
1127
            .uppercased()
1128
    }
1129

            
1130
    static func isValidMACAddress(_ macAddress: String) -> Bool {
1131
        macAddress.range(
1132
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
1133
            options: .regularExpression
1134
        ) != nil
1135
    }
1136
}
1137

            
1138
private final class ChargeNotificationCoordinator {
1139
    private struct Payload {
1140
        let id: String
1141
        let title: String
1142
        let body: String
1143
        let threadIdentifier: String
1144
    }
1145

            
1146
    private let notificationCenter = UNUserNotificationCenter.current()
1147
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
1148
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
1149
    private var inFlightEventIDs: Set<String> = []
1150

            
1151
    func ensureAuthorizationIfNeeded() {
1152
        notificationCenter.getNotificationSettings { [weak self] settings in
1153
            guard settings.authorizationStatus == .notDetermined else {
1154
                return
1155
            }
1156

            
1157
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
1158
                if let error {
1159
                    track("Notification authorization request failed: \(error.localizedDescription)")
1160
                }
1161
            }
1162
        }
1163
    }
1164

            
1165
    func process(chargedDevices: [ChargedDeviceSummary]) {
1166
        let now = Date()
1167
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
1168
            payloads(for: chargedDevice, now: now)
1169
        }
1170

            
1171
        for payload in pendingPayloads {
1172
            scheduleIfNeeded(payload)
1173
        }
1174
    }
1175

            
1176
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1177
        chargedDevice.sessions.compactMap { session in
1178
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1179
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1180
               let targetBatteryPercent = session.targetBatteryPercent {
1181
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1182
                    ?? session.endBatteryPercent
1183
                    ?? targetBatteryPercent
1184

            
1185
                return Payload(
1186
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1187
                    title: "Battery target reached",
1188
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1189
                    threadIdentifier: session.id.uuidString
1190
                )
1191
            }
1192

            
1193
            if session.requiresCompletionConfirmation,
1194
               let requestedAt = session.completionConfirmationRequestedAt,
1195
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
1196
                let estimatedPercent = session.completionContradictionPercent
1197
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1198
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
1199
                let detail = estimatedPercent.map {
1200
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
1201
                } ?? ""
1202

            
1203
                return Payload(
1204
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1205
                    title: "Confirm charge completion",
1206
                    body: bodyPrefix + detail,
1207
                    threadIdentifier: session.id.uuidString
1208
                )
1209
            }
1210

            
1211
            return nil
1212
        }
1213
    }
1214

            
1215
    private func scheduleIfNeeded(_ payload: Payload) {
1216
        guard deliveredEventIDs().contains(payload.id) == false else {
1217
            return
1218
        }
1219

            
1220
        guard inFlightEventIDs.contains(payload.id) == false else {
1221
            return
1222
        }
1223

            
1224
        inFlightEventIDs.insert(payload.id)
1225

            
1226
        notificationCenter.getNotificationSettings { [weak self] settings in
1227
            guard let self else { return }
1228
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1229
                DispatchQueue.main.async {
1230
                    self.inFlightEventIDs.remove(payload.id)
1231
                }
1232
                return
1233
            }
1234

            
1235
            let content = UNMutableNotificationContent()
1236
            content.title = payload.title
1237
            content.body = payload.body
1238
            content.sound = .default
1239
            content.threadIdentifier = payload.threadIdentifier
1240

            
1241
            let request = UNNotificationRequest(
1242
                identifier: payload.id,
1243
                content: content,
1244
                trigger: nil
1245
            )
1246

            
1247
            self.notificationCenter.add(request) { error in
1248
                DispatchQueue.main.async {
1249
                    self.inFlightEventIDs.remove(payload.id)
1250
                    if let error {
1251
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1252
                        return
1253
                    }
1254
                    self.storeDeliveredEventID(payload.id)
1255
                }
1256
            }
1257
        }
1258
    }
1259

            
1260
    private func deliveredEventIDs() -> Set<String> {
1261
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1262
        return Set(values)
1263
    }
1264

            
1265
    private func storeDeliveredEventID(_ id: String) {
1266
        var values = deliveredEventIDs()
1267
        values.insert(id)
1268
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1269
    }
Bogdan Timofte authored 2 months ago
1270
}