Newer Older
1453 lines | 53.571kb
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 a month ago
61
    private var meterSummariesCache: (version: Int, summaries: [MeterSummary])?
62
    private var meterSummariesVersion: Int = 0
Bogdan Timofte authored 2 months ago
63

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

            
Bogdan Timofte authored 2 months ago
87
    let bluetoothManager = BluetoothManager()
Bogdan Timofte authored 2 months ago
88

            
Bogdan Timofte authored 2 months ago
89
    @Published var enableRecordFeature: Bool = true
Bogdan Timofte authored 2 months ago
90

            
Bogdan Timofte authored a month ago
91
    @Published var meters: [UUID:Meter] = [UUID:Meter]() {
92
        didSet {
93
            invalidateMeterSummaries()
94
        }
95
    }
Bogdan Timofte authored a month ago
96
    @Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
Bogdan Timofte authored a month ago
97
    @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:]
Bogdan Timofte authored a month ago
98

            
99
    var deviceSummaries: [ChargedDeviceSummary] {
100
        chargedDevices.filter { !$0.isCharger }
101
    }
102

            
103
    var chargerSummaries: [ChargedDeviceSummary] {
104
        chargedDevices.filter { $0.isCharger }
105
    }
Bogdan Timofte authored 2 months ago
106

            
107
    var cloudAvailability: MeterNameStore.CloudAvailability {
108
        meterStore.currentCloudAvailability
109
    }
110

            
Bogdan Timofte authored a month ago
111
    func activateChargeInsights(context: NSManagedObjectContext) {
112
        guard chargeInsightsStore == nil else {
113
            return
114
        }
115

            
116
        context.automaticallyMergesChangesFromParent = true
Bogdan Timofte authored a month ago
117
        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
Bogdan Timofte authored a month ago
118
        if let coordinator = context.persistentStoreCoordinator {
Bogdan Timofte authored a month ago
119
            let writeContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
120
            writeContext.persistentStoreCoordinator = coordinator
121
            writeContext.automaticallyMergesChangesFromParent = false
122
            writeContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
123
            chargeInsightsStore = ChargeInsightsStore(context: writeContext)
124

            
Bogdan Timofte authored a month ago
125
            let readContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
126
            readContext.persistentStoreCoordinator = coordinator
127
            readContext.automaticallyMergesChangesFromParent = true
128
            readContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
129
            chargeInsightsReadStore = ChargeInsightsStore(context: readContext)
130

            
Bogdan Timofte authored a month ago
131
            chargeInsightsStoreObserver = NotificationCenter.default.publisher(
132
                for: .NSManagedObjectContextDidSave,
133
                object: writeContext
134
            )
135
            .sink { [weak self, weak context] notification in
136
                guard let self, let context else { return }
137
                context.perform {
138
                    context.mergeChanges(fromContextDidSave: notification)
139
                    DispatchQueue.main.async {
140
                        self.scheduleChargedDevicesReload()
141
                    }
142
                }
143
            }
144
        } else {
145
            chargeInsightsStore = ChargeInsightsStore(context: context)
146
            chargeInsightsReadStore = ChargeInsightsStore(context: context)
147

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

            
158
        chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
159
            for: .NSPersistentStoreRemoteChange,
160
            object: nil
161
        )
162
        .receive(on: DispatchQueue.main)
163
        .sink { [weak self] _ in
Bogdan Timofte authored a month ago
164
            self?.scheduleChargedDevicesReload()
Bogdan Timofte authored a month ago
165
        }
166

            
167
        chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
168
        reloadChargedDevices()
169
    }
170

            
Bogdan Timofte authored 2 months ago
171
    func meterName(for macAddress: String) -> String? {
172
        meterStore.name(for: macAddress)
173
    }
174

            
175
    func setMeterName(_ name: String, for macAddress: String) {
176
        meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
177
    }
178

            
179
    func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
180
        let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
181
        return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
182
    }
183

            
184
    func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
185
        meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
Bogdan Timofte authored 2 months ago
186
    }
Bogdan Timofte authored 2 months ago
187

            
Bogdan Timofte authored 2 months ago
188
    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
189
        meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
Bogdan Timofte authored 2 months ago
190
    }
191

            
192
    func noteMeterSeen(at date: Date, macAddress: String) {
Bogdan Timofte authored a month ago
193
        if let persistedLastSeen = meterStore.lastSeen(for: macAddress),
194
           date.timeIntervalSince(persistedLastSeen) < meterPresencePersistInterval {
195
            return
196
        }
Bogdan Timofte authored 2 months ago
197
        meterStore.noteLastSeen(date, for: macAddress)
198
    }
199

            
200
    func noteMeterConnected(at date: Date, macAddress: String) {
201
        meterStore.noteLastConnected(date, for: macAddress)
202
    }
203

            
204
    func lastSeen(for macAddress: String) -> Date? {
205
        meterStore.lastSeen(for: macAddress)
206
    }
207

            
208
    func lastConnected(for macAddress: String) -> Date? {
209
        meterStore.lastConnected(for: macAddress)
210
    }
211

            
Bogdan Timofte authored a month ago
212
    func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
213
        chargedDevices.first(where: { $0.id == id })
214
    }
215

            
Bogdan Timofte authored a month ago
216
    func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
217
        for chargedDevice in chargedDevices {
218
            if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
219
                return session
220
            }
221
        }
222
        return nil
223
    }
224

            
Bogdan Timofte authored a month ago
225
    func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
226
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
227
        return chargedDevices.filter { chargedDevice in
228
            guard chargedDevice.isCharger == false else {
229
                return false
230
            }
Bogdan Timofte authored a month ago
231
            return chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
232
        }
233
    }
234

            
235
    func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] {
236
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
237
        return chargedDevices.filter { chargedDevice in
238
            guard chargedDevice.isCharger else {
239
                return false
240
            }
Bogdan Timofte authored a month ago
241
            return chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
242
        }
243
    }
244

            
245
    func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
246
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
247

            
Bogdan Timofte authored a month ago
248
        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
Bogdan Timofte authored a month ago
249
           let liveDevice = chargedDevices.first(where: {
250
               $0.id == activeSession.chargedDeviceID && $0.isCharger == false
251
           }) {
252
            return liveDevice
253
        }
254

            
255
        return chargedDevices.first(where: {
256
            $0.isCharger == false && $0.lastAssociatedMeterMAC == normalizedMAC
257
        })
258
    }
259

            
260
    func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
261
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
262

            
Bogdan Timofte authored a month ago
263
        if let activeSession = cachedActiveChargeSessionSummary(for: normalizedMAC),
Bogdan Timofte authored a month ago
264
           let chargerID = activeSession.chargerID,
265
           let liveCharger = chargedDevices.first(where: {
266
               $0.id == chargerID && $0.isCharger
267
           }) {
268
            return liveCharger
269
        }
270

            
271
        return chargedDevices.first(where: {
272
            $0.isCharger && $0.lastAssociatedMeterMAC == normalizedMAC
273
        })
274
    }
275

            
276
    func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
Bogdan Timofte authored a month ago
277
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
278

            
Bogdan Timofte authored a month ago
279
        if expireOverlongChargeSessionsIfNeeded() {
280
            reloadChargedDevices()
281
            return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC)
282
        }
283

            
Bogdan Timofte authored a month ago
284
        if let cachedSummary = cachedActiveChargeSessionSummary(for: normalizedMAC) {
Bogdan Timofte authored a month ago
285
            if let persistedSummary = chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC),
286
               persistedSummary.aggregatedSamples.count > cachedSummary.aggregatedSamples.count {
287
                return persistedSummary
288
            }
Bogdan Timofte authored a month ago
289
            return cachedSummary
290
        }
Bogdan Timofte authored a month ago
291
        return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC)
Bogdan Timofte authored a month ago
292
    }
293

            
Bogdan Timofte authored a month ago
294
    func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
295
        activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)]
296
    }
297

            
298
    @discardableResult
299
    func startChargerStandbyMeasurement(for chargerID: UUID, on meter: Meter) -> Bool {
300
        guard chargedDeviceSummary(id: chargerID)?.isCharger == true else {
301
            return false
302
        }
303

            
304
        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
305
        if let existingSession = activeChargerStandbySessions[normalizedMAC] {
306
            return existingSession.chargerID == chargerID
307
        }
308

            
309
        let session = ChargerStandbyPowerMonitorSession(chargerID: chargerID, meter: meter)
310
        session.onChange = { [weak self] in
311
            self?.scheduleObjectWillChange()
312
        }
313
        session.onStabilized = { [weak self, weak session] in
314
            guard let self, let session else { return }
315
            self.notifyChargerStandbyMeasurementReady(for: session)
316
        }
317

            
318
        activeChargerStandbySessions[normalizedMAC] = session
319
        session.start()
320

            
321
        // Starting a standby run on an available meter should also initiate the BLE link.
322
        if meter.operationalState == .peripheralNotConnected {
323
            meter.connect()
324
        }
325

            
326
        scheduleObjectWillChange()
327
        return true
328
    }
329

            
330
    @discardableResult
331
    func finishChargerStandbyMeasurement(for meterMACAddress: String, save: Bool) -> Bool {
332
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
333
        guard let session = activeChargerStandbySessions[normalizedMAC] else {
334
            return false
335
        }
336

            
337
        session.stop()
338

            
339
        guard save else {
340
            activeChargerStandbySessions[normalizedMAC] = nil
341
            scheduleObjectWillChange()
342
            return true
343
        }
344

            
345
        guard let summary = session.makeSummary() else {
346
            scheduleObjectWillChange()
347
            return false
348
        }
349

            
350
        let didSave = chargerStandbyPowerStore.save(summary)
351
        if didSave {
352
            activeChargerStandbySessions[normalizedMAC] = nil
353
            reloadChargedDevices()
354
        } else {
355
            scheduleObjectWillChange()
356
        }
357

            
358
        return didSave
359
    }
360

            
Bogdan Timofte authored a month ago
361
    @discardableResult
362
    func deleteChargerStandbyMeasurement(id: UUID, chargerID: UUID) -> Bool {
363
        let didDelete = chargerStandbyPowerStore.removeMeasurement(id: id, chargerID: chargerID)
364
        if didDelete {
365
            reloadChargedDevices()
366
        } else {
367
            scheduleObjectWillChange()
368
        }
369
        return didDelete
370
    }
371

            
Bogdan Timofte authored a month ago
372
    @discardableResult
Bogdan Timofte authored a month ago
373
    func createDevice(
Bogdan Timofte authored a month ago
374
        name: String,
375
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
376
        templateID: String?,
Bogdan Timofte authored a month ago
377
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
378
        supportsWiredCharging: Bool,
379
        supportsWirelessCharging: Bool,
380
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
381
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
382
        notes: String?,
383
        meterMACAddress: String?
384
    ) -> Bool {
Bogdan Timofte authored a month ago
385
        let didSave = chargeInsightsStore?.createDevice(
Bogdan Timofte authored a month ago
386
            name: name,
387
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
388
            templateID: templateID,
Bogdan Timofte authored a month ago
389
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
390
            supportsWiredCharging: supportsWiredCharging,
391
            supportsWirelessCharging: supportsWirelessCharging,
392
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
393
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
394
            notes: notes,
395
            assignTo: meterMACAddress
396
        ) ?? false
397

            
398
        if didSave {
399
            reloadChargedDevices()
400
        }
401

            
402
        return didSave
403
    }
404

            
405
    @discardableResult
Bogdan Timofte authored a month ago
406
    func createCharger(
407
        name: String,
Bogdan Timofte authored a month ago
408
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
409
        notes: String?,
410
        meterMACAddress: String?
411
    ) -> Bool {
412
        let didSave = chargeInsightsStore?.createCharger(
413
            name: name,
Bogdan Timofte authored a month ago
414
            chargerType: chargerType,
Bogdan Timofte authored a month ago
415
            notes: notes,
416
            assignTo: meterMACAddress
417
        ) ?? false
418

            
419
        if didSave {
420
            reloadChargedDevices()
421
        }
422

            
423
        return didSave
424
    }
425

            
426
    @discardableResult
427
    func updateDevice(
Bogdan Timofte authored a month ago
428
        id: UUID,
429
        name: String,
430
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
431
        templateID: String?,
Bogdan Timofte authored a month ago
432
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
433
        supportsWiredCharging: Bool,
434
        supportsWirelessCharging: Bool,
435
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
436
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
437
        notes: String?
438
    ) -> Bool {
Bogdan Timofte authored a month ago
439
        let didSave = chargeInsightsStore?.updateDevice(
Bogdan Timofte authored a month ago
440
            id: id,
441
            name: name,
442
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
443
            templateID: templateID,
Bogdan Timofte authored a month ago
444
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
445
            supportsWiredCharging: supportsWiredCharging,
446
            supportsWirelessCharging: supportsWirelessCharging,
447
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
448
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
449
            notes: notes
450
        ) ?? false
451

            
452
        if didSave {
453
            reloadChargedDevices()
454
        }
455

            
456
        return didSave
457
    }
458

            
459
    @discardableResult
Bogdan Timofte authored a month ago
460
    func updateCharger(
461
        id: UUID,
462
        name: String,
Bogdan Timofte authored a month ago
463
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
464
        notes: String?
465
    ) -> Bool {
466
        let didSave = chargeInsightsStore?.updateCharger(
467
            id: id,
468
            name: name,
Bogdan Timofte authored a month ago
469
            chargerType: chargerType,
Bogdan Timofte authored a month ago
470
            notes: notes
Bogdan Timofte authored a month ago
471
        ) ?? false
472

            
473
        if didSave {
474
            reloadChargedDevices()
475
        }
476

            
477
        return didSave
478
    }
479

            
480
    @discardableResult
481
    func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool {
482
        let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false
483
        if didSave {
484
            reloadChargedDevices()
485
        }
486
        return didSave
487
    }
488

            
489
    @discardableResult
490
    func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
491
        let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false
492
        if didSave {
493
            reloadChargedDevices()
494
        }
495
        return didSave
496
    }
497

            
Bogdan Timofte authored a month ago
498
    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
499
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
500
            return
501
        }
Bogdan Timofte authored a month ago
502
        guard activeSession.status.isOpen else {
Bogdan Timofte authored a month ago
503
            return
504
        }
505
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
506
    }
507

            
Bogdan Timofte authored a month ago
508
    @discardableResult
Bogdan Timofte authored a month ago
509
    func startChargeSession(
510
        for meter: Meter,
511
        chargedDeviceID: UUID,
512
        chargerID: UUID?,
513
        chargingTransportMode: ChargingTransportMode,
514
        chargingStateMode: ChargingStateMode,
515
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
516
        initialBatteryPercent: Double?,
517
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
518
    ) -> Bool {
Bogdan Timofte authored a month ago
519
        meter.resetMeterCountersForNewSession()
520

            
Bogdan Timofte authored a month ago
521
        guard let snapshot = meter.chargingMonitorSnapshot else {
522
            return false
523
        }
524

            
Bogdan Timofte authored a month ago
525
        let didSave = chargeInsightsStore?.startSession(
526
            for: snapshot,
527
            chargedDeviceID: chargedDeviceID,
528
            chargerID: chargerID,
529
            chargingTransportMode: chargingTransportMode,
530
            chargingStateMode: chargingStateMode,
531
            autoStopEnabled: autoStopEnabled,
Bogdan Timofte authored a month ago
532
            initialBatteryPercent: initialBatteryPercent,
533
            startsFromFlatBattery: startsFromFlatBattery
Bogdan Timofte authored a month ago
534
        ) ?? false
535
        if didSave {
Bogdan Timofte authored a month ago
536
            meter.resetChargeRecordGraph()
Bogdan Timofte authored a month ago
537
            let activeSession = chargeInsightsStore?.activeChargeSessionSummary(
538
                forMeterMACAddress: meter.btSerial.macAddress.description
539
            )
540
            if let activeSession,
Bogdan Timofte authored a month ago
541
               meter.supportsRecordingThreshold,
542
               activeSession.stopThresholdAmps > 0 {
543
                meter.recordingTreshold = activeSession.stopThresholdAmps
544
            }
Bogdan Timofte authored a month ago
545
            if let activeSession {
546
                meter.restoreChargeMonitoringIfNeeded(from: activeSession)
547
            }
548
            reloadChargedDevices()
Bogdan Timofte authored a month ago
549
        }
550
        return didSave
551
    }
552

            
553
    @discardableResult
554
    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
555
        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
Bogdan Timofte authored a month ago
556

            
557
        if let meter {
558
            _ = persistChargeSnapshot(from: meter, observedAt: observedAt)
559
        } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
560
            _ = flushPendingChargeObservation(for: meterMACAddress)
561
        }
562

            
Bogdan Timofte authored a month ago
563
        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
564
        if didSave {
565
            reloadChargedDevices()
566
        }
567
        return didSave
568
    }
569

            
570
    @discardableResult
571
    func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
572
        let snapshot = meter?.chargingMonitorSnapshot
573
        let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
574
        if didSave {
575
            reloadChargedDevices()
576
        }
577
        return didSave
578
    }
579

            
580
    @discardableResult
Bogdan Timofte authored a month ago
581
    func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil, from meter: Meter? = nil) -> Bool {
582
        if let meter {
583
            _ = persistChargeSnapshot(from: meter)
584
        } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
Bogdan Timofte authored a month ago
585
            _ = flushPendingChargeObservation(for: meterMACAddress)
586
        }
587

            
Bogdan Timofte authored a month ago
588
        let didSave = chargeInsightsStore?.stopSession(
589
            id: sessionID,
Bogdan Timofte authored a month ago
590
            finalBatteryPercent: finalBatteryPercent
Bogdan Timofte authored a month ago
591
        ) ?? false
Bogdan Timofte authored a month ago
592
        reloadChargedDevices()
Bogdan Timofte authored a month ago
593
        return didSave
594
    }
595

            
596
    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
597
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
598
            return
599
        }
600

            
Bogdan Timofte authored a month ago
601
        stageChargeObservation(snapshot)
Bogdan Timofte authored a month ago
602
    }
603

            
604
    @discardableResult
Bogdan Timofte authored a month ago
605
    func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
Bogdan Timofte authored a month ago
606
        _ = persistChargeSnapshot(from: meter)
Bogdan Timofte authored a month ago
607

            
608
        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
609
        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
610
        let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
611

            
Bogdan Timofte authored a month ago
612
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
613
            percent: percent,
Bogdan Timofte authored a month ago
614
            for: meter.btSerial.macAddress.description,
615
            measuredEnergyWh: checkpointEnergyWh,
616
            measuredChargeAh: checkpointChargeAh
Bogdan Timofte authored a month ago
617
        ) ?? false
618

            
619
        if didSave {
620
            reloadChargedDevices()
621
        }
622

            
623
        return didSave
624
    }
625

            
626
    @discardableResult
Bogdan Timofte authored a month ago
627
    func addBatteryCheckpoint(percent: Double, for sessionID: UUID) -> Bool {
628
        guard canAddBatteryCheckpoint(to: sessionID) else {
629
            return false
630
        }
631

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

            
637
        if didSave {
638
            reloadChargedDevices()
639
        }
640

            
641
        return didSave
642
    }
643

            
Bogdan Timofte authored a month ago
644
    @discardableResult
645
    func addBatteryCheckpoint(
646
        percent: Double,
647
        for sessionID: UUID,
648
        measuredEnergyWh: Double?,
649
        measuredChargeAh: Double?
650
    ) -> Bool {
Bogdan Timofte authored a month ago
651
        guard canAddBatteryCheckpoint(to: sessionID) else {
652
            return false
653
        }
654

            
Bogdan Timofte authored a month ago
655
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
656
            percent: percent,
657
            for: sessionID,
658
            measuredEnergyWh: measuredEnergyWh,
659
            measuredChargeAh: measuredChargeAh
660
        ) ?? false
661

            
662
        if didSave {
663
            reloadChargedDevices()
664
        }
665

            
666
        return didSave
667
    }
668

            
Bogdan Timofte authored a month ago
669
    func canAddBatteryCheckpoint(to sessionID: UUID) -> Bool {
670
        guard let session = chargeSessionSummary(id: sessionID),
671
              session.status.isOpen,
672
              let meterMACAddress = session.meterMACAddress else {
673
            return false
674
        }
675

            
676
        return meter(for: meterMACAddress) != nil
677
    }
678

            
679
    func batteryCheckpointCaptureRequirementMessage(for sessionID: UUID) -> String? {
680
        guard let session = chargeSessionSummary(id: sessionID) else {
681
            return "Battery checkpoints are available only while the charge session is still active."
682
        }
683

            
684
        guard session.status.isOpen else {
685
            return "Battery checkpoints are available only while the charge session is still active."
686
        }
687

            
688
        guard let meterMACAddress = session.meterMACAddress,
689
              meter(for: meterMACAddress) != nil else {
690
            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."
691
        }
692

            
693
        return nil
694
    }
695

            
Bogdan Timofte authored a month ago
696
    func batteryCheckpointPlausibilityWarning(
697
        percent: Double,
Bogdan Timofte authored a month ago
698
        for sessionID: UUID,
699
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
700
    ) -> BatteryCheckpointPlausibilityWarning? {
701
        guard let session = chargeSessionSummary(id: sessionID) else {
702
            return nil
703
        }
Bogdan Timofte authored a month ago
704
        return batteryCheckpointPlausibilityWarning(
705
            percent: percent,
706
            for: session,
707
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
708
        )
Bogdan Timofte authored a month ago
709
    }
710

            
711
    @discardableResult
712
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
713
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
714
            id: checkpointID,
715
            from: sessionID
716
        ) ?? false
717

            
718
        if didDelete {
Bogdan Timofte authored a month ago
719
            reloadChargedDevices()
Bogdan Timofte authored a month ago
720
        }
721

            
722
        return didDelete
723
    }
724

            
Bogdan Timofte authored a month ago
725
    @discardableResult
726
    func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
727
        let didSave = chargeInsightsStore?.setSessionTrim(
728
            sessionID: sessionID,
729
            start: start,
730
            end: end
731
        ) ?? false
732
        if didSave {
733
            reloadChargedDevices()
734
        }
735
        return didSave
736
    }
737

            
Bogdan Timofte authored a month ago
738
    @discardableResult
739
    func flushChargeInsights() -> Bool {
Bogdan Timofte authored a month ago
740
        let didFlushObservations = flushAllPendingChargeObservations()
Bogdan Timofte authored a month ago
741
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
Bogdan Timofte authored a month ago
742
        if didFlushObservations || didSave {
743
            reloadChargedDevices()
744
        }
745
        return didFlushObservations || didSave
Bogdan Timofte authored a month ago
746
    }
747

            
748
    @discardableResult
749
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
750
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
751
            return false
752
        }
753
        return setTargetBatteryPercent(percent, for: activeSession.id)
754
    }
755

            
756
    @discardableResult
757
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
758
        if percent != nil {
759
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
760
        }
761

            
762
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
763
        if didSave {
764
            reloadChargedDevices()
765
        }
766
        return didSave
767
    }
768

            
769
    @discardableResult
770
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
771
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
772
        if didSave {
773
            reloadChargedDevices()
774
        }
775
        return didSave
776
    }
777

            
778
    @discardableResult
779
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
780
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
781
        if didSave {
782
            reloadChargedDevices()
783
        }
784
        return didSave
785
    }
786

            
787
    @discardableResult
788
    func deleteChargeSession(sessionID: UUID) -> Bool {
789
        let deletedSession = chargedDevices
790
            .flatMap(\.sessions)
791
            .first(where: { $0.id == sessionID })
792

            
793
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
794
        guard didDelete else {
795
            return false
796
        }
797

            
Bogdan Timofte authored a month ago
798
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
799
           let meterMACAddress = deletedSession?.meterMACAddress,
800
           let liveMeter = meter(for: meterMACAddress) {
801
            liveMeter.resetChargeRecord()
802
        }
803

            
804
        reloadChargedDevices()
805
        return true
806
    }
807

            
808
    @discardableResult
809
    func deleteChargedDevice(id: UUID) -> Bool {
810
        let deletedDevice = chargedDeviceSummary(id: id)
811
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
812
        guard didDelete else {
813
            return false
814
        }
815

            
Bogdan Timofte authored a month ago
816
        if deletedDevice?.isCharger == true {
817
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
818
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
819
                session.stop()
820
                activeChargerStandbySessions[meterMACAddress] = nil
821
            }
822
        }
823

            
Bogdan Timofte authored a month ago
824
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
825
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
826
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
827
           let liveMeter = meter(for: meterMACAddress) {
828
            liveMeter.resetChargeRecord()
829
        }
830

            
831
        reloadChargedDevices()
832
        return true
833
    }
834

            
835
    @discardableResult
836
    func createKnownMeter(
837
        macAddress: String,
838
        customName: String?,
839
        modelName: String,
840
        advertisedName: String?
841
    ) -> Bool {
842
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
843
        guard Self.isValidMACAddress(normalizedMAC) else {
844
            return false
845
        }
846

            
847
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
848
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
849
            setMeterName(customName, for: normalizedMAC)
850
        }
851
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
852
        return true
853
    }
854

            
855
    @discardableResult
856
    func deleteMeter(macAddress: String) -> Bool {
857
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
858
        guard Self.isValidMACAddress(normalizedMAC) else {
859
            return false
860
        }
861

            
862
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
863
            meter.disconnect()
864
        }
865
        meters = meters.filter { element in
866
            element.value.btSerial.macAddress.description != normalizedMAC
867
        }
868

            
869
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
870
        if didDelete {
871
            scheduleObjectWillChange()
872
        }
873
        return didDelete
874
    }
875

            
Bogdan Timofte authored 2 months ago
876
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored a month ago
877
        if let meterSummariesCache, meterSummariesCache.version == meterSummariesVersion {
878
            return meterSummariesCache.summaries
879
        }
880

            
Bogdan Timofte authored 2 months ago
881
        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
882
        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
883
        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
884

            
Bogdan Timofte authored a month ago
885
        let summaries = macAddresses.map { macAddress in
Bogdan Timofte authored 2 months ago
886
            let liveMeter = liveMetersByMAC[macAddress]
887
            let record = recordsByMAC[macAddress]
888

            
Bogdan Timofte authored 2 months ago
889
            return MeterSummary(
Bogdan Timofte authored 2 months ago
890
                macAddress: macAddress,
Bogdan Timofte authored 2 months ago
891
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
Bogdan Timofte authored 2 months ago
892
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
Bogdan Timofte authored 2 months ago
893
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
894
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
895
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
896
                meter: liveMeter
897
            )
898
        }
899
        .sorted { lhs, rhs in
Bogdan Timofte authored 2 months ago
900
            if lhs.meter != nil && rhs.meter == nil {
901
                return true
902
            }
903
            if lhs.meter == nil && rhs.meter != nil {
904
                return false
905
            }
Bogdan Timofte authored 2 months ago
906
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
907
            if byName != .orderedSame {
908
                return byName == .orderedAscending
909
            }
910
            return lhs.macAddress < rhs.macAddress
911
        }
Bogdan Timofte authored a month ago
912

            
913
        meterSummariesCache = (version: meterSummariesVersion, summaries: summaries)
914
        return summaries
Bogdan Timofte authored 2 months ago
915
    }
916

            
Bogdan Timofte authored 2 months ago
917
    private func scheduleObjectWillChange() {
918
        DispatchQueue.main.async { [weak self] in
919
            self?.objectWillChange.send()
920
        }
921
    }
Bogdan Timofte authored 2 months ago
922

            
Bogdan Timofte authored a month ago
923
    private func invalidateMeterSummaries() {
924
        meterSummariesVersion += 1
925
        meterSummariesCache = nil
926
    }
927

            
Bogdan Timofte authored a month ago
928
    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
929
        pendingChargedDevicesReloadWorkItem?.cancel()
930

            
931
        let workItem = DispatchWorkItem { [weak self] in
932
            self?.reloadChargedDevices()
933
        }
934
        pendingChargedDevicesReloadWorkItem = workItem
935
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
936
    }
937

            
Bogdan Timofte authored a month ago
938
    private func stageChargeObservation(_ snapshot: ChargingMonitorSnapshot, scheduleFlush: Bool = true) {
939
        let normalizedMAC = Self.normalizedMACAddress(snapshot.meterMACAddress)
940
        guard !normalizedMAC.isEmpty else {
941
            return
942
        }
943

            
944
        pendingChargeObservationSnapshots[normalizedMAC] = snapshot
945

            
946
        guard scheduleFlush else {
947
            return
948
        }
949

            
950
        guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
951
            return
952
        }
953

            
954
        let workItem = DispatchWorkItem { [weak self] in
955
            guard let self else { return }
956
            self.pendingChargeObservationWorkItems[normalizedMAC] = nil
957
            guard let snapshot = self.pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
958
                return
959
            }
960
            // CoreData write on background — DidSave observer handles the reload
961
            let store = self.chargeInsightsStore
962
            DispatchQueue.global(qos: .utility).async {
963
                store?.observe(snapshot: snapshot)
964
            }
965
        }
966
        pendingChargeObservationWorkItems[normalizedMAC] = workItem
967
        DispatchQueue.main.asyncAfter(deadline: .now() + chargeObservationPersistInterval, execute: workItem)
968
    }
969

            
970
    @discardableResult
971
    private func persistChargeSnapshot(from meter: Meter, observedAt: Date = Date()) -> Bool {
972
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
973
            return false
974
        }
975

            
976
        stageChargeObservation(snapshot, scheduleFlush: false)
977
        return flushPendingChargeObservation(for: snapshot.meterMACAddress)
978
    }
979

            
980
    @discardableResult
981
    private func flushPendingChargeObservation(for meterMACAddress: String) -> Bool {
982
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
983
        guard !normalizedMAC.isEmpty else {
984
            return false
985
        }
986

            
987
        pendingChargeObservationWorkItems[normalizedMAC]?.cancel()
988
        pendingChargeObservationWorkItems[normalizedMAC] = nil
989

            
990
        guard let snapshot = pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
991
            return false
992
        }
993

            
994
        let didSave = chargeInsightsStore?.observe(snapshot: snapshot) ?? false
995
        return didSave
996
    }
997

            
998
    @discardableResult
999
    private func flushAllPendingChargeObservations() -> Bool {
1000
        let pendingMeterMACAddresses = Array(pendingChargeObservationSnapshots.keys)
1001
        var didSave = false
1002

            
1003
        for meterMACAddress in pendingMeterMACAddresses {
1004
            if flushPendingChargeObservation(for: meterMACAddress) {
1005
                didSave = true
1006
            }
1007
        }
1008

            
1009
        return didSave
1010
    }
1011

            
Bogdan Timofte authored a month ago
1012
    private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
1013
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
1014
        guard !normalizedMAC.isEmpty else {
1015
            return nil
1016
        }
1017

            
1018
        return chargedDevices
1019
            .lazy
1020
            .compactMap(\.activeSession)
Bogdan Timofte authored a month ago
1021
            .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
1022
    }
1023

            
Bogdan Timofte authored a month ago
1024
    @discardableResult
1025
    private func healDuplicateOpenSessions() -> Bool {
1026
        chargeInsightsStore?.healDuplicateOpenSessions() ?? false
1027
    }
1028

            
Bogdan Timofte authored a month ago
1029
    @discardableResult
1030
    private func expireOverlongChargeSessionsIfNeeded(referenceDate: Date = Date()) -> Bool {
1031
        chargeInsightsStore?.completeExpiredOpenSessions(referenceDate: referenceDate) ?? false
1032
    }
1033

            
Bogdan Timofte authored a month ago
1034
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
1035
        if Thread.isMainThread == false {
1036
            DispatchQueue.main.async { [weak self] in
1037
                self?.reloadChargedDevices()
1038
            }
1039
            return
1040
        }
1041

            
Bogdan Timofte authored a month ago
1042
        pendingChargedDevicesReloadWorkItem?.cancel()
1043
        pendingChargedDevicesReloadWorkItem = nil
1044

            
Bogdan Timofte authored a month ago
1045
        _ = healDuplicateOpenSessions()
Bogdan Timofte authored a month ago
1046
        _ = expireOverlongChargeSessionsIfNeeded()
1047

            
Bogdan Timofte authored a month ago
1048
        guard chargedDevicesReloadInFlight == false else {
1049
            chargedDevicesReloadPending = true
1050
            return
1051
        }
1052

            
Bogdan Timofte authored a month ago
1053
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
Bogdan Timofte authored a month ago
1054
        let readStore = chargeInsightsReadStore ?? chargeInsightsStore
Bogdan Timofte authored a month ago
1055
        chargedDevicesReloadInFlight = true
1056
        chargedDevicesReloadPending = false
Bogdan Timofte authored a month ago
1057

            
1058
        chargedDevicesReloadQueue.async { [weak self] in
1059
            guard let self else { return }
1060

            
1061
            readStore?.resetContext()
1062
            let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
1063
                chargedDevice.withStandbyPowerMeasurements(
1064
                    standbyMeasurementsByChargerID[chargedDevice.id] ?? []
1065
                )
1066
            }
1067

            
1068
            DispatchQueue.main.async { [weak self] in
1069
                guard let self else { return }
1070

            
1071
                self.chargedDevices = summaries
1072
                self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
1073
                for meter in self.meters.values {
1074
                    self.restoreChargeMonitoringStateIfNeeded(for: meter)
1075
                }
Bogdan Timofte authored a month ago
1076

            
1077
                self.chargedDevicesReloadInFlight = false
1078
                if self.chargedDevicesReloadPending {
1079
                    self.reloadChargedDevices()
1080
                }
Bogdan Timofte authored a month ago
1081
            }
Bogdan Timofte authored a month ago
1082
        }
1083
    }
1084

            
1085
    private func meter(for meterMACAddress: String) -> Meter? {
1086
        meters.values.first { meter in
1087
            meter.btSerial.macAddress.description == meterMACAddress
1088
        }
1089
    }
1090

            
Bogdan Timofte authored 2 months ago
1091
    private func refreshMeterMetadata() {
1092
        DispatchQueue.main.async { [weak self] in
1093
            guard let self else { return }
1094
            var didUpdateAnyMeter = false
1095
            for meter in self.meters.values {
1096
                let mac = meter.btSerial.macAddress.description
1097
                let displayName = self.meterName(for: mac) ?? mac
1098
                if meter.name != displayName {
1099
                    meter.updateNameFromStore(displayName)
1100
                    didUpdateAnyMeter = true
1101
                }
1102

            
1103
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
1104
                meter.reloadTemperatureUnitPreference()
1105
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
1106
                    didUpdateAnyMeter = true
1107
                }
1108
            }
1109

            
1110
            if didUpdateAnyMeter {
1111
                self.scheduleObjectWillChange()
1112
            }
1113
        }
1114
    }
Bogdan Timofte authored a month ago
1115

            
Bogdan Timofte authored a month ago
1116
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
1117
        guard let charger = chargedDeviceSummary(id: session.chargerID),
1118
              let statistics = session.statistics else {
1119
            return
1120
        }
1121

            
1122
        let content = UNMutableNotificationContent()
1123
        content.title = "Standby baseline stabilised"
1124
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
1125
        content.sound = .default
1126
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
1127

            
1128
        let request = UNNotificationRequest(
1129
            identifier: "charger-standby-\(session.id.uuidString)",
1130
            content: content,
1131
            trigger: nil
1132
        )
1133
        UNUserNotificationCenter.current().add(request)
1134
        scheduleObjectWillChange()
1135
    }
1136

            
Bogdan Timofte authored a month ago
1137
    private func batteryCheckpointPlausibilityWarning(
1138
        percent: Double,
Bogdan Timofte authored a month ago
1139
        for session: ChargeSessionSummary,
1140
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
1141
    ) -> BatteryCheckpointPlausibilityWarning? {
1142
        guard percent.isFinite, percent >= 0, percent <= 100 else {
1143
            return nil
1144
        }
1145

            
1146
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
1147
            if lhs.timestamp != rhs.timestamp {
1148
                return lhs.timestamp < rhs.timestamp
1149
            }
1150
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1151
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1152
            }
1153
            return lhs.id.uuidString < rhs.id.uuidString
1154
        }
1155

            
1156
        if let lastCheckpoint = sortedCheckpoints.last,
1157
           percent < lastCheckpoint.batteryPercent - 1.5 {
1158
            return BatteryCheckpointPlausibilityWarning(
1159
                title: "Checkpoint Goes Backwards",
1160
                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."
1161
            )
1162
        }
1163

            
Bogdan Timofte authored a month ago
1164
        let effectiveEnergyWh = effectiveEnergyWhOverride
1165
            ?? session.effectiveBatteryEnergyWh
1166
            ?? session.measuredEnergyWh
1167

            
1168
        if let lastCheckpoint = sortedCheckpoints.last,
1169
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
1170
            let estimatedCapacityWh = session.capacityEstimateWh
1171
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1172
                ?? chargedDevice.estimatedBatteryCapacityWh
1173

            
1174
            if let estimatedCapacityWh, estimatedCapacityWh > 0 {
1175
                let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1176
                let expectedPercent = min(
1177
                    100,
1178
                    max(
1179
                        lastCheckpoint.batteryPercent,
1180
                        lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
1181
                    )
1182
                )
1183
                let predictionGap = percent - expectedPercent
1184
                guard abs(predictionGap) >= 4 else {
1185
                    return nil
1186
                }
1187

            
1188
                let direction = predictionGap > 0 ? "above" : "below"
1189
                let gapText = abs(predictionGap).format(decimalDigits: 0)
1190
                let expectedText = expectedPercent.format(decimalDigits: 0)
1191

            
1192
                return BatteryCheckpointPlausibilityWarning(
1193
                    title: "Checkpoint Looks Implausible",
1194
                    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."
1195
                )
1196
            }
1197
        }
1198

            
Bogdan Timofte authored a month ago
1199
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
Bogdan Timofte authored a month ago
1200
              let prediction = chargedDevice.batteryLevelPrediction(
1201
                for: session,
1202
                effectiveEnergyWhOverride: effectiveEnergyWh
1203
              )
Bogdan Timofte authored a month ago
1204
        else {
1205
            return nil
1206
        }
1207

            
1208
        let predictionGap = percent - prediction.predictedPercent
1209
        guard abs(predictionGap) >= 4 else {
1210
            return nil
1211
        }
1212

            
1213
        let direction = predictionGap > 0 ? "above" : "below"
1214
        let gapText = abs(predictionGap).format(decimalDigits: 0)
1215
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
1216

            
1217
        if let lastCheckpoint = sortedCheckpoints.last {
1218
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1219
            return BatteryCheckpointPlausibilityWarning(
1220
                title: "Checkpoint Looks Implausible",
1221
                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."
1222
            )
1223
        }
1224

            
1225
        return BatteryCheckpointPlausibilityWarning(
1226
            title: "Checkpoint Looks Implausible",
1227
            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."
1228
        )
1229
    }
Bogdan Timofte authored a month ago
1230

            
1231
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1232
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
Bogdan Timofte authored a month ago
1233
        guard session.isTrimmed == false else {
1234
            return storedEnergyWh
1235
        }
Bogdan Timofte authored a month ago
1236
        guard session.status.isOpen else {
1237
            return storedEnergyWh
1238
        }
1239

            
1240
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1241
            return storedEnergyWh
1242
        }
1243

            
1244
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1245
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
1246
        }
1247

            
1248
        return storedEnergyWh
1249
    }
1250

            
1251
    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1252
        let storedChargeAh = session.measuredChargeAh
Bogdan Timofte authored a month ago
1253
        guard session.isTrimmed == false else {
1254
            return storedChargeAh
1255
        }
Bogdan Timofte authored a month ago
1256
        guard session.status.isOpen else {
1257
            return storedChargeAh
1258
        }
1259

            
1260
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1261
            return storedChargeAh
1262
        }
1263

            
1264
        if let baselineChargeAh = session.meterChargeBaselineAh {
1265
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
1266
        }
1267

            
1268
        return storedChargeAh
1269
    }
Bogdan Timofte authored 2 months ago
1270
}
Bogdan Timofte authored 2 months ago
1271

            
1272
extension AppData.MeterSummary {
1273
    var tint: Color {
1274
        switch modelSummary {
1275
        case "UM25C":
1276
            return .blue
1277
        case "UM34C":
1278
            return .yellow
1279
        case "TC66C":
1280
            return Model.TC66C.color
1281
        default:
1282
            return .secondary
1283
        }
1284
    }
1285
}
Bogdan Timofte authored 2 months ago
1286

            
Bogdan Timofte authored a month ago
1287
extension AppData {
Bogdan Timofte authored 2 months ago
1288
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
1289
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
1290
            return liveName
1291
        }
1292
        if let customName = record?.customName {
1293
            return customName
1294
        }
1295
        if let advertisedName = record?.advertisedName {
1296
            return advertisedName
1297
        }
1298
        if let recordModel = record?.modelName {
1299
            return recordModel
1300
        }
1301
        if let liveModel = liveMeter?.deviceModelSummary {
1302
            return liveModel
1303
        }
1304
        return "Meter"
1305
    }
Bogdan Timofte authored a month ago
1306

            
1307
    static func normalizedMACAddress(_ macAddress: String) -> String {
1308
        macAddress
1309
            .trimmingCharacters(in: .whitespacesAndNewlines)
1310
            .uppercased()
1311
    }
1312

            
1313
    static func isValidMACAddress(_ macAddress: String) -> Bool {
1314
        macAddress.range(
1315
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
1316
            options: .regularExpression
1317
        ) != nil
1318
    }
1319
}
1320

            
1321
private final class ChargeNotificationCoordinator {
1322
    private struct Payload {
1323
        let id: String
1324
        let title: String
1325
        let body: String
1326
        let threadIdentifier: String
1327
    }
1328

            
1329
    private let notificationCenter = UNUserNotificationCenter.current()
1330
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
1331
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
1332
    private var inFlightEventIDs: Set<String> = []
1333

            
1334
    func ensureAuthorizationIfNeeded() {
1335
        notificationCenter.getNotificationSettings { [weak self] settings in
1336
            guard settings.authorizationStatus == .notDetermined else {
1337
                return
1338
            }
1339

            
1340
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
1341
                if let error {
1342
                    track("Notification authorization request failed: \(error.localizedDescription)")
1343
                }
1344
            }
1345
        }
1346
    }
1347

            
1348
    func process(chargedDevices: [ChargedDeviceSummary]) {
1349
        let now = Date()
1350
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
1351
            payloads(for: chargedDevice, now: now)
1352
        }
1353

            
1354
        for payload in pendingPayloads {
1355
            scheduleIfNeeded(payload)
1356
        }
1357
    }
1358

            
1359
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1360
        chargedDevice.sessions.compactMap { session in
1361
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1362
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1363
               let targetBatteryPercent = session.targetBatteryPercent {
1364
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1365
                    ?? session.endBatteryPercent
1366
                    ?? targetBatteryPercent
1367

            
1368
                return Payload(
1369
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1370
                    title: "Battery target reached",
1371
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1372
                    threadIdentifier: session.id.uuidString
1373
                )
1374
            }
1375

            
1376
            if session.requiresCompletionConfirmation,
1377
               let requestedAt = session.completionConfirmationRequestedAt,
1378
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
1379
                let estimatedPercent = session.completionContradictionPercent
1380
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1381
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
1382
                let detail = estimatedPercent.map {
1383
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
1384
                } ?? ""
1385

            
1386
                return Payload(
1387
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1388
                    title: "Confirm charge completion",
1389
                    body: bodyPrefix + detail,
1390
                    threadIdentifier: session.id.uuidString
1391
                )
1392
            }
1393

            
1394
            return nil
1395
        }
1396
    }
1397

            
1398
    private func scheduleIfNeeded(_ payload: Payload) {
1399
        guard deliveredEventIDs().contains(payload.id) == false else {
1400
            return
1401
        }
1402

            
1403
        guard inFlightEventIDs.contains(payload.id) == false else {
1404
            return
1405
        }
1406

            
1407
        inFlightEventIDs.insert(payload.id)
1408

            
1409
        notificationCenter.getNotificationSettings { [weak self] settings in
1410
            guard let self else { return }
1411
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1412
                DispatchQueue.main.async {
1413
                    self.inFlightEventIDs.remove(payload.id)
1414
                }
1415
                return
1416
            }
1417

            
1418
            let content = UNMutableNotificationContent()
1419
            content.title = payload.title
1420
            content.body = payload.body
1421
            content.sound = .default
1422
            content.threadIdentifier = payload.threadIdentifier
1423

            
1424
            let request = UNNotificationRequest(
1425
                identifier: payload.id,
1426
                content: content,
1427
                trigger: nil
1428
            )
1429

            
1430
            self.notificationCenter.add(request) { error in
1431
                DispatchQueue.main.async {
1432
                    self.inFlightEventIDs.remove(payload.id)
1433
                    if let error {
1434
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1435
                        return
1436
                    }
1437
                    self.storeDeliveredEventID(payload.id)
1438
                }
1439
            }
1440
        }
1441
    }
1442

            
1443
    private func deliveredEventIDs() -> Set<String> {
1444
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1445
        return Set(values)
1446
    }
1447

            
1448
    private func storeDeliveredEventID(_ id: String) {
1449
        var values = deliveredEventIDs()
1450
        values.insert(id)
1451
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1452
    }
Bogdan Timofte authored 2 months ago
1453
}