Newer Older
1385 lines | 51.219kb
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?
Bogdan Timofte authored a month ago
47
    private var pendingChargeObservationSnapshots: [String: ChargingMonitorSnapshot] = [:]
48
    private var pendingChargeObservationWorkItems: [String: DispatchWorkItem] = [:]
Bogdan Timofte authored a month ago
49
    private let chargedDevicesReloadQueue = DispatchQueue(
50
        label: "ro.xdev.usb-meter.charged-devices-reload",
51
        qos: .userInitiated
52
    )
Bogdan Timofte authored a month ago
53
    private var chargedDevicesReloadInFlight = false
54
    private var chargedDevicesReloadPending = false
Bogdan Timofte authored a month ago
55
    private let chargeObservationPersistInterval: TimeInterval = 30
56
    private let meterPresencePersistInterval: TimeInterval = 15
Bogdan Timofte authored 2 months ago
57
    private let meterStore = MeterNameStore.shared
Bogdan Timofte authored a month ago
58
    private var chargeInsightsStore: ChargeInsightsStore?
Bogdan Timofte authored a month ago
59
    private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
Bogdan Timofte authored a month ago
60
    private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
Bogdan Timofte authored 2 months ago
61

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

            
Bogdan Timofte authored 2 months ago
83
    let bluetoothManager = BluetoothManager()
Bogdan Timofte authored 2 months ago
84

            
Bogdan Timofte authored 2 months ago
85
    @Published var enableRecordFeature: Bool = true
Bogdan Timofte authored 2 months ago
86

            
Bogdan Timofte authored 2 months ago
87
    @Published var meters: [UUID:Meter] = [UUID:Meter]()
Bogdan Timofte authored a month ago
88
    @Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
Bogdan Timofte authored a month ago
89
    @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:]
Bogdan Timofte authored a month ago
90

            
91
    var deviceSummaries: [ChargedDeviceSummary] {
92
        chargedDevices.filter { !$0.isCharger }
93
    }
94

            
95
    var chargerSummaries: [ChargedDeviceSummary] {
96
        chargedDevices.filter { $0.isCharger }
97
    }
Bogdan Timofte authored 2 months ago
98

            
99
    var cloudAvailability: MeterNameStore.CloudAvailability {
100
        meterStore.currentCloudAvailability
101
    }
102

            
Bogdan Timofte authored a month ago
103
    func activateChargeInsights(context: NSManagedObjectContext) {
104
        guard chargeInsightsStore == nil else {
105
            return
106
        }
107

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

            
Bogdan Timofte authored a month ago
117
            let readContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
118
            readContext.persistentStoreCoordinator = coordinator
119
            readContext.automaticallyMergesChangesFromParent = true
120
            readContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
121
            chargeInsightsReadStore = ChargeInsightsStore(context: readContext)
122

            
Bogdan Timofte authored a month ago
123
            chargeInsightsStoreObserver = NotificationCenter.default.publisher(
124
                for: .NSManagedObjectContextDidSave,
125
                object: writeContext
126
            )
127
            .sink { [weak self, weak context] notification in
128
                guard let self, let context else { return }
129
                context.perform {
130
                    context.mergeChanges(fromContextDidSave: notification)
131
                    DispatchQueue.main.async {
132
                        self.scheduleChargedDevicesReload()
133
                    }
134
                }
135
            }
136
        } else {
137
            chargeInsightsStore = ChargeInsightsStore(context: context)
138
            chargeInsightsReadStore = ChargeInsightsStore(context: context)
139

            
140
            chargeInsightsStoreObserver = NotificationCenter.default.publisher(
141
                for: .NSManagedObjectContextDidSave,
142
                object: context
143
            )
144
            .receive(on: DispatchQueue.main)
145
            .sink { [weak self] _ in
146
                self?.scheduleChargedDevicesReload()
147
            }
Bogdan Timofte authored a month ago
148
        }
149

            
150
        chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
151
            for: .NSPersistentStoreRemoteChange,
152
            object: nil
153
        )
154
        .receive(on: DispatchQueue.main)
155
        .sink { [weak self] _ in
Bogdan Timofte authored a month ago
156
            self?.scheduleChargedDevicesReload()
Bogdan Timofte authored a month ago
157
        }
158

            
159
        chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
160
        reloadChargedDevices()
161
    }
162

            
Bogdan Timofte authored 2 months ago
163
    func meterName(for macAddress: String) -> String? {
164
        meterStore.name(for: macAddress)
165
    }
166

            
167
    func setMeterName(_ name: String, for macAddress: String) {
168
        meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
169
    }
170

            
171
    func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
172
        let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
173
        return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
174
    }
175

            
176
    func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
177
        meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
Bogdan Timofte authored 2 months ago
178
    }
Bogdan Timofte authored 2 months ago
179

            
Bogdan Timofte authored 2 months ago
180
    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
181
        meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
Bogdan Timofte authored 2 months ago
182
    }
183

            
184
    func noteMeterSeen(at date: Date, macAddress: String) {
Bogdan Timofte authored a month ago
185
        if let persistedLastSeen = meterStore.lastSeen(for: macAddress),
186
           date.timeIntervalSince(persistedLastSeen) < meterPresencePersistInterval {
187
            return
188
        }
Bogdan Timofte authored 2 months ago
189
        meterStore.noteLastSeen(date, for: macAddress)
190
    }
191

            
192
    func noteMeterConnected(at date: Date, macAddress: String) {
193
        meterStore.noteLastConnected(date, for: macAddress)
194
    }
195

            
196
    func lastSeen(for macAddress: String) -> Date? {
197
        meterStore.lastSeen(for: macAddress)
198
    }
199

            
200
    func lastConnected(for macAddress: String) -> Date? {
201
        meterStore.lastConnected(for: macAddress)
202
    }
203

            
Bogdan Timofte authored a month ago
204
    func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
205
        chargedDevices.first(where: { $0.id == id })
206
    }
207

            
Bogdan Timofte authored a month ago
208
    func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
209
        for chargedDevice in chargedDevices {
210
            if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
211
                return session
212
            }
213
        }
214
        return nil
215
    }
216

            
Bogdan Timofte authored a month ago
217
    func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
218
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
219
        return chargedDevices.filter { chargedDevice in
220
            guard chargedDevice.isCharger == false else {
221
                return false
222
            }
223
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
224
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
225
        }
226
    }
227

            
228
    func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] {
229
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
230
        return chargedDevices.filter { chargedDevice in
231
            guard chargedDevice.isCharger else {
232
                return false
233
            }
234
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
235
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
236
        }
237
    }
238

            
239
    func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
240
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
241

            
Bogdan Timofte authored a month ago
242
        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
Bogdan Timofte authored a month ago
243
           let liveDevice = chargedDevices.first(where: {
244
               $0.id == activeSession.chargedDeviceID && $0.isCharger == false
245
           }) {
246
            return liveDevice
247
        }
248

            
249
        return chargedDevices.first(where: {
250
            $0.isCharger == false && $0.lastAssociatedMeterMAC == normalizedMAC
251
        })
252
    }
253

            
254
    func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
255
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
256

            
Bogdan Timofte authored a month ago
257
        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
Bogdan Timofte authored a month ago
258
           let chargerID = activeSession.chargerID,
259
           let liveCharger = chargedDevices.first(where: {
260
               $0.id == chargerID && $0.isCharger
261
           }) {
262
            return liveCharger
263
        }
264

            
265
        return chargedDevices.first(where: {
266
            $0.isCharger && $0.lastAssociatedMeterMAC == normalizedMAC
267
        })
268
    }
269

            
270
    func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
Bogdan Timofte authored a month ago
271
        if let cachedSummary = cachedActiveChargeSessionSummary(for: meterMACAddress) {
272
            return cachedSummary
273
        }
274
        return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
Bogdan Timofte authored a month ago
275
    }
276

            
Bogdan Timofte authored a month ago
277
    func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
278
        activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)]
279
    }
280

            
281
    @discardableResult
282
    func startChargerStandbyMeasurement(for chargerID: UUID, on meter: Meter) -> Bool {
283
        guard chargedDeviceSummary(id: chargerID)?.isCharger == true else {
284
            return false
285
        }
286

            
287
        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
288
        if let existingSession = activeChargerStandbySessions[normalizedMAC] {
289
            return existingSession.chargerID == chargerID
290
        }
291

            
292
        let session = ChargerStandbyPowerMonitorSession(chargerID: chargerID, meter: meter)
293
        session.onChange = { [weak self] in
294
            self?.scheduleObjectWillChange()
295
        }
296
        session.onStabilized = { [weak self, weak session] in
297
            guard let self, let session else { return }
298
            self.notifyChargerStandbyMeasurementReady(for: session)
299
        }
300

            
301
        activeChargerStandbySessions[normalizedMAC] = session
302
        session.start()
303

            
304
        // Starting a standby run on an available meter should also initiate the BLE link.
305
        if meter.operationalState == .peripheralNotConnected {
306
            meter.connect()
307
        }
308

            
309
        scheduleObjectWillChange()
310
        return true
311
    }
312

            
313
    @discardableResult
314
    func finishChargerStandbyMeasurement(for meterMACAddress: String, save: Bool) -> Bool {
315
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
316
        guard let session = activeChargerStandbySessions[normalizedMAC] else {
317
            return false
318
        }
319

            
320
        session.stop()
321

            
322
        guard save else {
323
            activeChargerStandbySessions[normalizedMAC] = nil
324
            scheduleObjectWillChange()
325
            return true
326
        }
327

            
328
        guard let summary = session.makeSummary() else {
329
            scheduleObjectWillChange()
330
            return false
331
        }
332

            
333
        let didSave = chargerStandbyPowerStore.save(summary)
334
        if didSave {
335
            activeChargerStandbySessions[normalizedMAC] = nil
336
            reloadChargedDevices()
337
        } else {
338
            scheduleObjectWillChange()
339
        }
340

            
341
        return didSave
342
    }
343

            
Bogdan Timofte authored a month ago
344
    @discardableResult
345
    func deleteChargerStandbyMeasurement(id: UUID, chargerID: UUID) -> Bool {
346
        let didDelete = chargerStandbyPowerStore.removeMeasurement(id: id, chargerID: chargerID)
347
        if didDelete {
348
            reloadChargedDevices()
349
        } else {
350
            scheduleObjectWillChange()
351
        }
352
        return didDelete
353
    }
354

            
Bogdan Timofte authored a month ago
355
    @discardableResult
Bogdan Timofte authored a month ago
356
    func createDevice(
Bogdan Timofte authored a month ago
357
        name: String,
358
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
359
        templateID: String?,
Bogdan Timofte authored a month ago
360
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
361
        supportsWiredCharging: Bool,
362
        supportsWirelessCharging: Bool,
363
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
364
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
365
        notes: String?,
366
        meterMACAddress: String?
367
    ) -> Bool {
Bogdan Timofte authored a month ago
368
        let didSave = chargeInsightsStore?.createDevice(
Bogdan Timofte authored a month ago
369
            name: name,
370
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
371
            templateID: templateID,
Bogdan Timofte authored a month ago
372
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
373
            supportsWiredCharging: supportsWiredCharging,
374
            supportsWirelessCharging: supportsWirelessCharging,
375
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
376
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
377
            notes: notes,
378
            assignTo: meterMACAddress
379
        ) ?? false
380

            
381
        if didSave {
382
            reloadChargedDevices()
383
        }
384

            
385
        return didSave
386
    }
387

            
388
    @discardableResult
Bogdan Timofte authored a month ago
389
    func createCharger(
390
        name: String,
Bogdan Timofte authored a month ago
391
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
392
        notes: String?,
393
        meterMACAddress: String?
394
    ) -> Bool {
395
        let didSave = chargeInsightsStore?.createCharger(
396
            name: name,
Bogdan Timofte authored a month ago
397
            chargerType: chargerType,
Bogdan Timofte authored a month ago
398
            notes: notes,
399
            assignTo: meterMACAddress
400
        ) ?? false
401

            
402
        if didSave {
403
            reloadChargedDevices()
404
        }
405

            
406
        return didSave
407
    }
408

            
409
    @discardableResult
410
    func updateDevice(
Bogdan Timofte authored a month ago
411
        id: UUID,
412
        name: String,
413
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
414
        templateID: String?,
Bogdan Timofte authored a month ago
415
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
416
        supportsWiredCharging: Bool,
417
        supportsWirelessCharging: Bool,
418
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
419
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
420
        notes: String?
421
    ) -> Bool {
Bogdan Timofte authored a month ago
422
        let didSave = chargeInsightsStore?.updateDevice(
Bogdan Timofte authored a month ago
423
            id: id,
424
            name: name,
425
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
426
            templateID: templateID,
Bogdan Timofte authored a month ago
427
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
428
            supportsWiredCharging: supportsWiredCharging,
429
            supportsWirelessCharging: supportsWirelessCharging,
430
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
431
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
432
            notes: notes
433
        ) ?? false
434

            
435
        if didSave {
436
            reloadChargedDevices()
437
        }
438

            
439
        return didSave
440
    }
441

            
442
    @discardableResult
Bogdan Timofte authored a month ago
443
    func updateCharger(
444
        id: UUID,
445
        name: String,
Bogdan Timofte authored a month ago
446
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
447
        notes: String?
448
    ) -> Bool {
449
        let didSave = chargeInsightsStore?.updateCharger(
450
            id: id,
451
            name: name,
Bogdan Timofte authored a month ago
452
            chargerType: chargerType,
Bogdan Timofte authored a month ago
453
            notes: notes
Bogdan Timofte authored a month ago
454
        ) ?? false
455

            
456
        if didSave {
457
            reloadChargedDevices()
458
        }
459

            
460
        return didSave
461
    }
462

            
463
    @discardableResult
464
    func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool {
465
        let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false
466
        if didSave {
467
            reloadChargedDevices()
468
        }
469
        return didSave
470
    }
471

            
472
    @discardableResult
473
    func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
474
        let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false
475
        if didSave {
476
            reloadChargedDevices()
477
        }
478
        return didSave
479
    }
480

            
Bogdan Timofte authored a month ago
481
    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
482
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
483
            return
484
        }
485
        guard activeSession.status == .active else {
486
            return
487
        }
488
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
489
    }
490

            
Bogdan Timofte authored a month ago
491
    @discardableResult
Bogdan Timofte authored a month ago
492
    func startChargeSession(
493
        for meter: Meter,
494
        chargedDeviceID: UUID,
495
        chargerID: UUID?,
496
        chargingTransportMode: ChargingTransportMode,
497
        chargingStateMode: ChargingStateMode,
498
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
499
        initialBatteryPercent: Double?,
500
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
501
    ) -> Bool {
Bogdan Timofte authored a month ago
502
        meter.resetMeterCountersForNewSession()
503

            
Bogdan Timofte authored a month ago
504
        guard let snapshot = meter.chargingMonitorSnapshot else {
505
            return false
506
        }
507

            
Bogdan Timofte authored a month ago
508
        let didSave = chargeInsightsStore?.startSession(
509
            for: snapshot,
510
            chargedDeviceID: chargedDeviceID,
511
            chargerID: chargerID,
512
            chargingTransportMode: chargingTransportMode,
513
            chargingStateMode: chargingStateMode,
514
            autoStopEnabled: autoStopEnabled,
Bogdan Timofte authored a month ago
515
            initialBatteryPercent: initialBatteryPercent,
516
            startsFromFlatBattery: startsFromFlatBattery
Bogdan Timofte authored a month ago
517
        ) ?? false
518
        if didSave {
519
            reloadChargedDevices()
Bogdan Timofte authored a month ago
520
            meter.resetChargeRecordGraph()
521
            if let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description),
522
               meter.supportsRecordingThreshold,
523
               activeSession.stopThresholdAmps > 0 {
524
                meter.recordingTreshold = activeSession.stopThresholdAmps
525
            }
526
            restoreChargeMonitoringStateIfNeeded(for: meter)
Bogdan Timofte authored a month ago
527
        }
528
        return didSave
529
    }
530

            
531
    @discardableResult
532
    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
533
        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
Bogdan Timofte authored a month ago
534

            
535
        if let meter {
536
            _ = persistChargeSnapshot(from: meter, observedAt: observedAt)
537
        } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
538
            _ = flushPendingChargeObservation(for: meterMACAddress)
539
        }
540

            
Bogdan Timofte authored a month ago
541
        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
542
        if didSave {
543
            reloadChargedDevices()
544
        }
545
        return didSave
546
    }
547

            
548
    @discardableResult
549
    func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
550
        let snapshot = meter?.chargingMonitorSnapshot
551
        let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
552
        if didSave {
553
            reloadChargedDevices()
554
        }
555
        return didSave
556
    }
557

            
558
    @discardableResult
Bogdan Timofte authored a month ago
559
    func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil) -> Bool {
Bogdan Timofte authored a month ago
560
        if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
561
            _ = flushPendingChargeObservation(for: meterMACAddress)
562
        }
563

            
Bogdan Timofte authored a month ago
564
        let didSave = chargeInsightsStore?.stopSession(
565
            id: sessionID,
Bogdan Timofte authored a month ago
566
            finalBatteryPercent: finalBatteryPercent
Bogdan Timofte authored a month ago
567
        ) ?? false
Bogdan Timofte authored a month ago
568
        reloadChargedDevices()
Bogdan Timofte authored a month ago
569
        return didSave
570
    }
571

            
572
    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
573
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
574
            return
575
        }
576

            
Bogdan Timofte authored a month ago
577
        stageChargeObservation(snapshot)
Bogdan Timofte authored a month ago
578
    }
579

            
580
    @discardableResult
Bogdan Timofte authored a month ago
581
    func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
Bogdan Timofte authored a month ago
582
        _ = persistChargeSnapshot(from: meter)
Bogdan Timofte authored a month ago
583

            
584
        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
585
        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
586
        let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
587

            
Bogdan Timofte authored a month ago
588
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
589
            percent: percent,
Bogdan Timofte authored a month ago
590
            for: meter.btSerial.macAddress.description,
591
            measuredEnergyWh: checkpointEnergyWh,
592
            measuredChargeAh: checkpointChargeAh
Bogdan Timofte authored a month ago
593
        ) ?? false
594

            
595
        if didSave {
596
            reloadChargedDevices()
597
        }
598

            
599
        return didSave
600
    }
601

            
602
    @discardableResult
Bogdan Timofte authored a month ago
603
    func addBatteryCheckpoint(percent: Double, for sessionID: UUID) -> Bool {
604
        guard canAddBatteryCheckpoint(to: sessionID) else {
605
            return false
606
        }
607

            
Bogdan Timofte authored a month ago
608
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
609
            percent: percent,
610
            for: sessionID
611
        ) ?? false
612

            
613
        if didSave {
614
            reloadChargedDevices()
615
        }
616

            
617
        return didSave
618
    }
619

            
Bogdan Timofte authored a month ago
620
    @discardableResult
621
    func addBatteryCheckpoint(
622
        percent: Double,
623
        for sessionID: UUID,
624
        measuredEnergyWh: Double?,
625
        measuredChargeAh: Double?
626
    ) -> Bool {
Bogdan Timofte authored a month ago
627
        guard canAddBatteryCheckpoint(to: sessionID) else {
628
            return false
629
        }
630

            
Bogdan Timofte authored a month ago
631
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
632
            percent: percent,
633
            for: sessionID,
634
            measuredEnergyWh: measuredEnergyWh,
635
            measuredChargeAh: measuredChargeAh
636
        ) ?? false
637

            
638
        if didSave {
639
            reloadChargedDevices()
640
        }
641

            
642
        return didSave
643
    }
644

            
Bogdan Timofte authored a month ago
645
    func canAddBatteryCheckpoint(to sessionID: UUID) -> Bool {
646
        guard let session = chargeSessionSummary(id: sessionID),
647
              session.status.isOpen,
648
              let meterMACAddress = session.meterMACAddress else {
649
            return false
650
        }
651

            
652
        return meter(for: meterMACAddress) != nil
653
    }
654

            
655
    func batteryCheckpointCaptureRequirementMessage(for sessionID: UUID) -> String? {
656
        guard let session = chargeSessionSummary(id: sessionID) else {
657
            return "Battery checkpoints are available only while the charge session is still active."
658
        }
659

            
660
        guard session.status.isOpen else {
661
            return "Battery checkpoints are available only while the charge session is still active."
662
        }
663

            
664
        guard let meterMACAddress = session.meterMACAddress,
665
              meter(for: meterMACAddress) != nil else {
666
            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."
667
        }
668

            
669
        return nil
670
    }
671

            
Bogdan Timofte authored a month ago
672
    func batteryCheckpointPlausibilityWarning(
673
        percent: Double,
Bogdan Timofte authored a month ago
674
        for sessionID: UUID,
675
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
676
    ) -> BatteryCheckpointPlausibilityWarning? {
677
        guard let session = chargeSessionSummary(id: sessionID) else {
678
            return nil
679
        }
Bogdan Timofte authored a month ago
680
        return batteryCheckpointPlausibilityWarning(
681
            percent: percent,
682
            for: session,
683
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
684
        )
Bogdan Timofte authored a month ago
685
    }
686

            
687
    @discardableResult
688
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
689
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
690
            id: checkpointID,
691
            from: sessionID
692
        ) ?? false
693

            
694
        if didDelete {
Bogdan Timofte authored a month ago
695
            reloadChargedDevices()
Bogdan Timofte authored a month ago
696
        }
697

            
698
        return didDelete
699
    }
700

            
Bogdan Timofte authored a month ago
701
    @discardableResult
702
    func flushChargeInsights() -> Bool {
Bogdan Timofte authored a month ago
703
        let didFlushObservations = flushAllPendingChargeObservations()
Bogdan Timofte authored a month ago
704
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
Bogdan Timofte authored a month ago
705
        if didFlushObservations || didSave {
706
            reloadChargedDevices()
707
        }
708
        return didFlushObservations || didSave
Bogdan Timofte authored a month ago
709
    }
710

            
711
    @discardableResult
712
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
713
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
714
            return false
715
        }
716
        return setTargetBatteryPercent(percent, for: activeSession.id)
717
    }
718

            
719
    @discardableResult
720
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
721
        if percent != nil {
722
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
723
        }
724

            
725
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
726
        if didSave {
727
            reloadChargedDevices()
728
        }
729
        return didSave
730
    }
731

            
732
    @discardableResult
733
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
734
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
735
        if didSave {
736
            reloadChargedDevices()
737
        }
738
        return didSave
739
    }
740

            
741
    @discardableResult
742
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
743
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
744
        if didSave {
745
            reloadChargedDevices()
746
        }
747
        return didSave
748
    }
749

            
750
    @discardableResult
751
    func deleteChargeSession(sessionID: UUID) -> Bool {
752
        let deletedSession = chargedDevices
753
            .flatMap(\.sessions)
754
            .first(where: { $0.id == sessionID })
755

            
756
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
757
        guard didDelete else {
758
            return false
759
        }
760

            
Bogdan Timofte authored a month ago
761
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
762
           let meterMACAddress = deletedSession?.meterMACAddress,
763
           let liveMeter = meter(for: meterMACAddress) {
764
            liveMeter.resetChargeRecord()
765
        }
766

            
767
        reloadChargedDevices()
768
        return true
769
    }
770

            
771
    @discardableResult
772
    func deleteChargedDevice(id: UUID) -> Bool {
773
        let deletedDevice = chargedDeviceSummary(id: id)
774
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
775
        guard didDelete else {
776
            return false
777
        }
778

            
Bogdan Timofte authored a month ago
779
        if deletedDevice?.isCharger == true {
780
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
781
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
782
                session.stop()
783
                activeChargerStandbySessions[meterMACAddress] = nil
784
            }
785
        }
786

            
Bogdan Timofte authored a month ago
787
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
788
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
789
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
790
           let liveMeter = meter(for: meterMACAddress) {
791
            liveMeter.resetChargeRecord()
792
        }
793

            
794
        reloadChargedDevices()
795
        return true
796
    }
797

            
798
    @discardableResult
799
    func createKnownMeter(
800
        macAddress: String,
801
        customName: String?,
802
        modelName: String,
803
        advertisedName: String?
804
    ) -> Bool {
805
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
806
        guard Self.isValidMACAddress(normalizedMAC) else {
807
            return false
808
        }
809

            
810
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
811
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
812
            setMeterName(customName, for: normalizedMAC)
813
        }
814
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
815
        return true
816
    }
817

            
818
    @discardableResult
819
    func deleteMeter(macAddress: String) -> Bool {
820
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
821
        guard Self.isValidMACAddress(normalizedMAC) else {
822
            return false
823
        }
824

            
825
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
826
            meter.disconnect()
827
        }
828
        meters = meters.filter { element in
829
            element.value.btSerial.macAddress.description != normalizedMAC
830
        }
831

            
832
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
833
        if didDelete {
834
            scheduleObjectWillChange()
835
        }
836
        return didDelete
837
    }
838

            
Bogdan Timofte authored 2 months ago
839
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored 2 months ago
840
        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
841
        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
842
        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
843

            
844
        return macAddresses.map { macAddress in
845
            let liveMeter = liveMetersByMAC[macAddress]
846
            let record = recordsByMAC[macAddress]
847

            
Bogdan Timofte authored 2 months ago
848
            return MeterSummary(
Bogdan Timofte authored 2 months ago
849
                macAddress: macAddress,
Bogdan Timofte authored 2 months ago
850
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
Bogdan Timofte authored 2 months ago
851
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
Bogdan Timofte authored 2 months ago
852
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
853
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
854
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
855
                meter: liveMeter
856
            )
857
        }
858
        .sorted { lhs, rhs in
Bogdan Timofte authored 2 months ago
859
            if lhs.meter != nil && rhs.meter == nil {
860
                return true
861
            }
862
            if lhs.meter == nil && rhs.meter != nil {
863
                return false
864
            }
Bogdan Timofte authored 2 months ago
865
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
866
            if byName != .orderedSame {
867
                return byName == .orderedAscending
868
            }
869
            return lhs.macAddress < rhs.macAddress
870
        }
871
    }
872

            
Bogdan Timofte authored 2 months ago
873
    private func scheduleObjectWillChange() {
874
        DispatchQueue.main.async { [weak self] in
875
            self?.objectWillChange.send()
876
        }
877
    }
Bogdan Timofte authored 2 months ago
878

            
Bogdan Timofte authored a month ago
879
    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
880
        pendingChargedDevicesReloadWorkItem?.cancel()
881

            
882
        let workItem = DispatchWorkItem { [weak self] in
883
            self?.reloadChargedDevices()
884
        }
885
        pendingChargedDevicesReloadWorkItem = workItem
886
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
887
    }
888

            
Bogdan Timofte authored a month ago
889
    private func stageChargeObservation(_ snapshot: ChargingMonitorSnapshot, scheduleFlush: Bool = true) {
890
        let normalizedMAC = Self.normalizedMACAddress(snapshot.meterMACAddress)
891
        guard !normalizedMAC.isEmpty else {
892
            return
893
        }
894

            
895
        pendingChargeObservationSnapshots[normalizedMAC] = snapshot
896

            
897
        guard scheduleFlush else {
898
            return
899
        }
900

            
901
        guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
902
            return
903
        }
904

            
905
        let workItem = DispatchWorkItem { [weak self] in
906
            guard let self else { return }
907
            self.pendingChargeObservationWorkItems[normalizedMAC] = nil
908
            guard let snapshot = self.pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
909
                return
910
            }
911
            // CoreData write on background — DidSave observer handles the reload
912
            let store = self.chargeInsightsStore
913
            DispatchQueue.global(qos: .utility).async {
914
                store?.observe(snapshot: snapshot)
915
            }
916
        }
917
        pendingChargeObservationWorkItems[normalizedMAC] = workItem
918
        DispatchQueue.main.asyncAfter(deadline: .now() + chargeObservationPersistInterval, execute: workItem)
919
    }
920

            
921
    @discardableResult
922
    private func persistChargeSnapshot(from meter: Meter, observedAt: Date = Date()) -> Bool {
923
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
924
            return false
925
        }
926

            
927
        stageChargeObservation(snapshot, scheduleFlush: false)
928
        return flushPendingChargeObservation(for: snapshot.meterMACAddress)
929
    }
930

            
931
    @discardableResult
932
    private func flushPendingChargeObservation(for meterMACAddress: String) -> Bool {
933
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
934
        guard !normalizedMAC.isEmpty else {
935
            return false
936
        }
937

            
938
        pendingChargeObservationWorkItems[normalizedMAC]?.cancel()
939
        pendingChargeObservationWorkItems[normalizedMAC] = nil
940

            
941
        guard let snapshot = pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
942
            return false
943
        }
944

            
945
        let didSave = chargeInsightsStore?.observe(snapshot: snapshot) ?? false
946
        return didSave
947
    }
948

            
949
    @discardableResult
950
    private func flushAllPendingChargeObservations() -> Bool {
951
        let pendingMeterMACAddresses = Array(pendingChargeObservationSnapshots.keys)
952
        var didSave = false
953

            
954
        for meterMACAddress in pendingMeterMACAddresses {
955
            if flushPendingChargeObservation(for: meterMACAddress) {
956
                didSave = true
957
            }
958
        }
959

            
960
        return didSave
961
    }
962

            
Bogdan Timofte authored a month ago
963
    private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
964
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
965
        guard !normalizedMAC.isEmpty else {
966
            return nil
967
        }
968

            
969
        return chargedDevices
970
            .lazy
971
            .compactMap(\.activeSession)
Bogdan Timofte authored a month ago
972
            .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
973
    }
974

            
Bogdan Timofte authored a month ago
975
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
976
        if Thread.isMainThread == false {
977
            DispatchQueue.main.async { [weak self] in
978
                self?.reloadChargedDevices()
979
            }
980
            return
981
        }
982

            
Bogdan Timofte authored a month ago
983
        pendingChargedDevicesReloadWorkItem?.cancel()
984
        pendingChargedDevicesReloadWorkItem = nil
985

            
Bogdan Timofte authored a month ago
986
        guard chargedDevicesReloadInFlight == false else {
987
            chargedDevicesReloadPending = true
988
            return
989
        }
990

            
Bogdan Timofte authored a month ago
991
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
Bogdan Timofte authored a month ago
992
        let readStore = chargeInsightsReadStore ?? chargeInsightsStore
Bogdan Timofte authored a month ago
993
        chargedDevicesReloadInFlight = true
994
        chargedDevicesReloadPending = false
Bogdan Timofte authored a month ago
995

            
996
        chargedDevicesReloadQueue.async { [weak self] in
997
            guard let self else { return }
998

            
999
            readStore?.resetContext()
1000
            let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
1001
                chargedDevice.withStandbyPowerMeasurements(
1002
                    standbyMeasurementsByChargerID[chargedDevice.id] ?? []
1003
                )
1004
            }
1005

            
1006
            DispatchQueue.main.async { [weak self] in
1007
                guard let self else { return }
1008

            
1009
                self.chargedDevices = summaries
1010
                self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
1011
                for meter in self.meters.values {
1012
                    self.restoreChargeMonitoringStateIfNeeded(for: meter)
1013
                }
Bogdan Timofte authored a month ago
1014

            
1015
                self.chargedDevicesReloadInFlight = false
1016
                if self.chargedDevicesReloadPending {
1017
                    self.reloadChargedDevices()
1018
                }
Bogdan Timofte authored a month ago
1019
            }
Bogdan Timofte authored a month ago
1020
        }
1021
    }
1022

            
1023
    private func meter(for meterMACAddress: String) -> Meter? {
1024
        meters.values.first { meter in
1025
            meter.btSerial.macAddress.description == meterMACAddress
1026
        }
1027
    }
1028

            
Bogdan Timofte authored 2 months ago
1029
    private func refreshMeterMetadata() {
1030
        DispatchQueue.main.async { [weak self] in
1031
            guard let self else { return }
1032
            var didUpdateAnyMeter = false
1033
            for meter in self.meters.values {
1034
                let mac = meter.btSerial.macAddress.description
1035
                let displayName = self.meterName(for: mac) ?? mac
1036
                if meter.name != displayName {
1037
                    meter.updateNameFromStore(displayName)
1038
                    didUpdateAnyMeter = true
1039
                }
1040

            
1041
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
1042
                meter.reloadTemperatureUnitPreference()
1043
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
1044
                    didUpdateAnyMeter = true
1045
                }
1046
            }
1047

            
1048
            if didUpdateAnyMeter {
1049
                self.scheduleObjectWillChange()
1050
            }
1051
        }
1052
    }
Bogdan Timofte authored a month ago
1053

            
Bogdan Timofte authored a month ago
1054
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
1055
        guard let charger = chargedDeviceSummary(id: session.chargerID),
1056
              let statistics = session.statistics else {
1057
            return
1058
        }
1059

            
1060
        let content = UNMutableNotificationContent()
1061
        content.title = "Standby baseline stabilised"
1062
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
1063
        content.sound = .default
1064
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
1065

            
1066
        let request = UNNotificationRequest(
1067
            identifier: "charger-standby-\(session.id.uuidString)",
1068
            content: content,
1069
            trigger: nil
1070
        )
1071
        UNUserNotificationCenter.current().add(request)
1072
        scheduleObjectWillChange()
1073
    }
1074

            
Bogdan Timofte authored a month ago
1075
    private func batteryCheckpointPlausibilityWarning(
1076
        percent: Double,
Bogdan Timofte authored a month ago
1077
        for session: ChargeSessionSummary,
1078
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
1079
    ) -> BatteryCheckpointPlausibilityWarning? {
1080
        guard percent.isFinite, percent >= 0, percent <= 100 else {
1081
            return nil
1082
        }
1083

            
1084
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
1085
            if lhs.timestamp != rhs.timestamp {
1086
                return lhs.timestamp < rhs.timestamp
1087
            }
1088
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1089
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1090
            }
1091
            return lhs.id.uuidString < rhs.id.uuidString
1092
        }
1093

            
1094
        if let lastCheckpoint = sortedCheckpoints.last,
1095
           percent < lastCheckpoint.batteryPercent - 1.5 {
1096
            return BatteryCheckpointPlausibilityWarning(
1097
                title: "Checkpoint Goes Backwards",
1098
                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."
1099
            )
1100
        }
1101

            
Bogdan Timofte authored a month ago
1102
        let effectiveEnergyWh = effectiveEnergyWhOverride
1103
            ?? session.effectiveBatteryEnergyWh
1104
            ?? session.measuredEnergyWh
1105

            
1106
        if let lastCheckpoint = sortedCheckpoints.last,
1107
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
1108
            let estimatedCapacityWh = session.capacityEstimateWh
1109
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1110
                ?? chargedDevice.estimatedBatteryCapacityWh
1111

            
1112
            if let estimatedCapacityWh, estimatedCapacityWh > 0 {
1113
                let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1114
                let expectedPercent = min(
1115
                    100,
1116
                    max(
1117
                        lastCheckpoint.batteryPercent,
1118
                        lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
1119
                    )
1120
                )
1121
                let predictionGap = percent - expectedPercent
1122
                guard abs(predictionGap) >= 4 else {
1123
                    return nil
1124
                }
1125

            
1126
                let direction = predictionGap > 0 ? "above" : "below"
1127
                let gapText = abs(predictionGap).format(decimalDigits: 0)
1128
                let expectedText = expectedPercent.format(decimalDigits: 0)
1129

            
1130
                return BatteryCheckpointPlausibilityWarning(
1131
                    title: "Checkpoint Looks Implausible",
1132
                    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."
1133
                )
1134
            }
1135
        }
1136

            
Bogdan Timofte authored a month ago
1137
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
Bogdan Timofte authored a month ago
1138
              let prediction = chargedDevice.batteryLevelPrediction(
1139
                for: session,
1140
                effectiveEnergyWhOverride: effectiveEnergyWh
1141
              )
Bogdan Timofte authored a month ago
1142
        else {
1143
            return nil
1144
        }
1145

            
1146
        let predictionGap = percent - prediction.predictedPercent
1147
        guard abs(predictionGap) >= 4 else {
1148
            return nil
1149
        }
1150

            
1151
        let direction = predictionGap > 0 ? "above" : "below"
1152
        let gapText = abs(predictionGap).format(decimalDigits: 0)
1153
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
1154

            
1155
        if let lastCheckpoint = sortedCheckpoints.last {
1156
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1157
            return BatteryCheckpointPlausibilityWarning(
1158
                title: "Checkpoint Looks Implausible",
1159
                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."
1160
            )
1161
        }
1162

            
1163
        return BatteryCheckpointPlausibilityWarning(
1164
            title: "Checkpoint Looks Implausible",
1165
            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."
1166
        )
1167
    }
Bogdan Timofte authored a month ago
1168

            
1169
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1170
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
1171
        guard session.status.isOpen else {
1172
            return storedEnergyWh
1173
        }
1174

            
1175
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1176
            return storedEnergyWh
1177
        }
1178

            
1179
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1180
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
1181
        }
1182

            
1183
        return storedEnergyWh
1184
    }
1185

            
1186
    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1187
        let storedChargeAh = session.measuredChargeAh
1188
        guard session.status.isOpen else {
1189
            return storedChargeAh
1190
        }
1191

            
1192
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1193
            return storedChargeAh
1194
        }
1195

            
1196
        if let baselineChargeAh = session.meterChargeBaselineAh {
1197
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
1198
        }
1199

            
1200
        return storedChargeAh
1201
    }
Bogdan Timofte authored 2 months ago
1202
}
Bogdan Timofte authored 2 months ago
1203

            
1204
extension AppData.MeterSummary {
1205
    var tint: Color {
1206
        switch modelSummary {
1207
        case "UM25C":
1208
            return .blue
1209
        case "UM34C":
1210
            return .yellow
1211
        case "TC66C":
1212
            return Model.TC66C.color
1213
        default:
1214
            return .secondary
1215
        }
1216
    }
1217
}
Bogdan Timofte authored 2 months ago
1218

            
Bogdan Timofte authored a month ago
1219
extension AppData {
Bogdan Timofte authored 2 months ago
1220
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
1221
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
1222
            return liveName
1223
        }
1224
        if let customName = record?.customName {
1225
            return customName
1226
        }
1227
        if let advertisedName = record?.advertisedName {
1228
            return advertisedName
1229
        }
1230
        if let recordModel = record?.modelName {
1231
            return recordModel
1232
        }
1233
        if let liveModel = liveMeter?.deviceModelSummary {
1234
            return liveModel
1235
        }
1236
        return "Meter"
1237
    }
Bogdan Timofte authored a month ago
1238

            
1239
    static func normalizedMACAddress(_ macAddress: String) -> String {
1240
        macAddress
1241
            .trimmingCharacters(in: .whitespacesAndNewlines)
1242
            .uppercased()
1243
    }
1244

            
1245
    static func isValidMACAddress(_ macAddress: String) -> Bool {
1246
        macAddress.range(
1247
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
1248
            options: .regularExpression
1249
        ) != nil
1250
    }
1251
}
1252

            
1253
private final class ChargeNotificationCoordinator {
1254
    private struct Payload {
1255
        let id: String
1256
        let title: String
1257
        let body: String
1258
        let threadIdentifier: String
1259
    }
1260

            
1261
    private let notificationCenter = UNUserNotificationCenter.current()
1262
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
1263
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
1264
    private var inFlightEventIDs: Set<String> = []
1265

            
1266
    func ensureAuthorizationIfNeeded() {
1267
        notificationCenter.getNotificationSettings { [weak self] settings in
1268
            guard settings.authorizationStatus == .notDetermined else {
1269
                return
1270
            }
1271

            
1272
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
1273
                if let error {
1274
                    track("Notification authorization request failed: \(error.localizedDescription)")
1275
                }
1276
            }
1277
        }
1278
    }
1279

            
1280
    func process(chargedDevices: [ChargedDeviceSummary]) {
1281
        let now = Date()
1282
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
1283
            payloads(for: chargedDevice, now: now)
1284
        }
1285

            
1286
        for payload in pendingPayloads {
1287
            scheduleIfNeeded(payload)
1288
        }
1289
    }
1290

            
1291
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1292
        chargedDevice.sessions.compactMap { session in
1293
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1294
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1295
               let targetBatteryPercent = session.targetBatteryPercent {
1296
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1297
                    ?? session.endBatteryPercent
1298
                    ?? targetBatteryPercent
1299

            
1300
                return Payload(
1301
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1302
                    title: "Battery target reached",
1303
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1304
                    threadIdentifier: session.id.uuidString
1305
                )
1306
            }
1307

            
1308
            if session.requiresCompletionConfirmation,
1309
               let requestedAt = session.completionConfirmationRequestedAt,
1310
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
1311
                let estimatedPercent = session.completionContradictionPercent
1312
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1313
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
1314
                let detail = estimatedPercent.map {
1315
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
1316
                } ?? ""
1317

            
1318
                return Payload(
1319
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1320
                    title: "Confirm charge completion",
1321
                    body: bodyPrefix + detail,
1322
                    threadIdentifier: session.id.uuidString
1323
                )
1324
            }
1325

            
1326
            return nil
1327
        }
1328
    }
1329

            
1330
    private func scheduleIfNeeded(_ payload: Payload) {
1331
        guard deliveredEventIDs().contains(payload.id) == false else {
1332
            return
1333
        }
1334

            
1335
        guard inFlightEventIDs.contains(payload.id) == false else {
1336
            return
1337
        }
1338

            
1339
        inFlightEventIDs.insert(payload.id)
1340

            
1341
        notificationCenter.getNotificationSettings { [weak self] settings in
1342
            guard let self else { return }
1343
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1344
                DispatchQueue.main.async {
1345
                    self.inFlightEventIDs.remove(payload.id)
1346
                }
1347
                return
1348
            }
1349

            
1350
            let content = UNMutableNotificationContent()
1351
            content.title = payload.title
1352
            content.body = payload.body
1353
            content.sound = .default
1354
            content.threadIdentifier = payload.threadIdentifier
1355

            
1356
            let request = UNNotificationRequest(
1357
                identifier: payload.id,
1358
                content: content,
1359
                trigger: nil
1360
            )
1361

            
1362
            self.notificationCenter.add(request) { error in
1363
                DispatchQueue.main.async {
1364
                    self.inFlightEventIDs.remove(payload.id)
1365
                    if let error {
1366
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1367
                        return
1368
                    }
1369
                    self.storeDeliveredEventID(payload.id)
1370
                }
1371
            }
1372
        }
1373
    }
1374

            
1375
    private func deliveredEventIDs() -> Set<String> {
1376
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1377
        return Set(values)
1378
    }
1379

            
1380
    private func storeDeliveredEventID(_ id: String) {
1381
        var values = deliveredEventIDs()
1382
        values.insert(id)
1383
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1384
    }
Bogdan Timofte authored 2 months ago
1385
}