Newer Older
1431 lines | 52.671kb
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

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

            
617
        if didSave {
618
            reloadChargedDevices()
619
        }
620

            
621
        return didSave
622
    }
623

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

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

            
635
        if didSave {
636
            reloadChargedDevices()
637
        }
638

            
639
        return didSave
640
    }
641

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

            
Bogdan Timofte authored a month ago
652
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
653
            percent: percent,
654
            for: sessionID,
Bogdan Timofte authored a month ago
655
            measuredEnergyWh: measuredEnergyWh
Bogdan Timofte authored a month ago
656
        ) ?? false
657

            
658
        if didSave {
659
            reloadChargedDevices()
660
        }
661

            
662
        return didSave
663
    }
664

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

            
672
        return meter(for: meterMACAddress) != nil
673
    }
674

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

            
680
        guard session.status.isOpen else {
681
            return "Battery checkpoints are available only while the charge session is still active."
682
        }
683

            
684
        guard let meterMACAddress = session.meterMACAddress,
685
              meter(for: meterMACAddress) != nil else {
686
            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."
687
        }
688

            
689
        return nil
690
    }
691

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

            
707
    @discardableResult
708
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
709
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
710
            id: checkpointID,
711
            from: sessionID
712
        ) ?? false
713

            
714
        if didDelete {
Bogdan Timofte authored a month ago
715
            reloadChargedDevices()
Bogdan Timofte authored a month ago
716
        }
717

            
718
        return didDelete
719
    }
720

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

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

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

            
752
    @discardableResult
753
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
754
        if percent != nil {
755
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
756
        }
757

            
758
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
759
        if didSave {
760
            reloadChargedDevices()
761
        }
762
        return didSave
763
    }
764

            
765
    @discardableResult
766
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
767
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
768
        if didSave {
769
            reloadChargedDevices()
770
        }
771
        return didSave
772
    }
773

            
774
    @discardableResult
775
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
776
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
777
        if didSave {
778
            reloadChargedDevices()
779
        }
780
        return didSave
781
    }
782

            
783
    @discardableResult
784
    func deleteChargeSession(sessionID: UUID) -> Bool {
785
        let deletedSession = chargedDevices
786
            .flatMap(\.sessions)
787
            .first(where: { $0.id == sessionID })
788

            
789
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
790
        guard didDelete else {
791
            return false
792
        }
793

            
Bogdan Timofte authored a month ago
794
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
795
           let meterMACAddress = deletedSession?.meterMACAddress,
796
           let liveMeter = meter(for: meterMACAddress) {
797
            liveMeter.resetChargeRecord()
798
        }
799

            
800
        reloadChargedDevices()
801
        return true
802
    }
803

            
804
    @discardableResult
805
    func deleteChargedDevice(id: UUID) -> Bool {
806
        let deletedDevice = chargedDeviceSummary(id: id)
807
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
808
        guard didDelete else {
809
            return false
810
        }
811

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

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

            
827
        reloadChargedDevices()
828
        return true
829
    }
830

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

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

            
851
    @discardableResult
852
    func deleteMeter(macAddress: String) -> Bool {
853
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
854
        guard Self.isValidMACAddress(normalizedMAC) else {
855
            return false
856
        }
857

            
858
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
859
            meter.disconnect()
860
        }
861
        meters = meters.filter { element in
862
            element.value.btSerial.macAddress.description != normalizedMAC
863
        }
864

            
865
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
866
        if didDelete {
867
            scheduleObjectWillChange()
868
        }
869
        return didDelete
870
    }
871

            
Bogdan Timofte authored 2 months ago
872
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored a month ago
873
        if let meterSummariesCache, meterSummariesCache.version == meterSummariesVersion {
874
            return meterSummariesCache.summaries
875
        }
876

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

            
Bogdan Timofte authored a month ago
881
        let summaries = macAddresses.map { macAddress in
Bogdan Timofte authored 2 months ago
882
            let liveMeter = liveMetersByMAC[macAddress]
883
            let record = recordsByMAC[macAddress]
884

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

            
909
        meterSummariesCache = (version: meterSummariesVersion, summaries: summaries)
910
        return summaries
Bogdan Timofte authored 2 months ago
911
    }
912

            
Bogdan Timofte authored 2 months ago
913
    private func scheduleObjectWillChange() {
914
        DispatchQueue.main.async { [weak self] in
915
            self?.objectWillChange.send()
916
        }
917
    }
Bogdan Timofte authored 2 months ago
918

            
Bogdan Timofte authored a month ago
919
    private func invalidateMeterSummaries() {
920
        meterSummariesVersion += 1
921
        meterSummariesCache = nil
922
    }
923

            
Bogdan Timofte authored a month ago
924
    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
925
        pendingChargedDevicesReloadWorkItem?.cancel()
926

            
927
        let workItem = DispatchWorkItem { [weak self] in
928
            self?.reloadChargedDevices()
929
        }
930
        pendingChargedDevicesReloadWorkItem = workItem
931
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
932
    }
933

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

            
940
        pendingChargeObservationSnapshots[normalizedMAC] = snapshot
941

            
942
        guard scheduleFlush else {
943
            return
944
        }
945

            
946
        guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
947
            return
948
        }
949

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

            
966
    @discardableResult
967
    private func persistChargeSnapshot(from meter: Meter, observedAt: Date = Date()) -> Bool {
968
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
969
            return false
970
        }
971

            
972
        stageChargeObservation(snapshot, scheduleFlush: false)
973
        return flushPendingChargeObservation(for: snapshot.meterMACAddress)
974
    }
975

            
976
    @discardableResult
977
    private func flushPendingChargeObservation(for meterMACAddress: String) -> Bool {
978
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
979
        guard !normalizedMAC.isEmpty else {
980
            return false
981
        }
982

            
983
        pendingChargeObservationWorkItems[normalizedMAC]?.cancel()
984
        pendingChargeObservationWorkItems[normalizedMAC] = nil
985

            
986
        guard let snapshot = pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
987
            return false
988
        }
989

            
990
        let didSave = chargeInsightsStore?.observe(snapshot: snapshot) ?? false
991
        return didSave
992
    }
993

            
994
    @discardableResult
995
    private func flushAllPendingChargeObservations() -> Bool {
996
        let pendingMeterMACAddresses = Array(pendingChargeObservationSnapshots.keys)
997
        var didSave = false
998

            
999
        for meterMACAddress in pendingMeterMACAddresses {
1000
            if flushPendingChargeObservation(for: meterMACAddress) {
1001
                didSave = true
1002
            }
1003
        }
1004

            
1005
        return didSave
1006
    }
1007

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

            
1014
        return chargedDevices
1015
            .lazy
1016
            .compactMap(\.activeSession)
Bogdan Timofte authored a month ago
1017
            .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
1018
    }
1019

            
Bogdan Timofte authored a month ago
1020
    @discardableResult
1021
    private func healDuplicateOpenSessions() -> Bool {
1022
        chargeInsightsStore?.healDuplicateOpenSessions() ?? false
1023
    }
1024

            
Bogdan Timofte authored a month ago
1025
    @discardableResult
1026
    private func expireOverlongChargeSessionsIfNeeded(referenceDate: Date = Date()) -> Bool {
1027
        chargeInsightsStore?.completeExpiredOpenSessions(referenceDate: referenceDate) ?? false
1028
    }
1029

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

            
Bogdan Timofte authored a month ago
1038
        pendingChargedDevicesReloadWorkItem?.cancel()
1039
        pendingChargedDevicesReloadWorkItem = nil
1040

            
Bogdan Timofte authored a month ago
1041
        _ = healDuplicateOpenSessions()
Bogdan Timofte authored a month ago
1042
        _ = expireOverlongChargeSessionsIfNeeded()
1043

            
Bogdan Timofte authored a month ago
1044
        guard chargedDevicesReloadInFlight == false else {
1045
            chargedDevicesReloadPending = true
1046
            return
1047
        }
1048

            
Bogdan Timofte authored a month ago
1049
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
Bogdan Timofte authored a month ago
1050
        let readStore = chargeInsightsReadStore ?? chargeInsightsStore
Bogdan Timofte authored a month ago
1051
        chargedDevicesReloadInFlight = true
1052
        chargedDevicesReloadPending = false
Bogdan Timofte authored a month ago
1053

            
1054
        chargedDevicesReloadQueue.async { [weak self] in
1055
            guard let self else { return }
1056

            
1057
            readStore?.resetContext()
1058
            let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
1059
                chargedDevice.withStandbyPowerMeasurements(
1060
                    standbyMeasurementsByChargerID[chargedDevice.id] ?? []
1061
                )
1062
            }
1063

            
1064
            DispatchQueue.main.async { [weak self] in
1065
                guard let self else { return }
1066

            
1067
                self.chargedDevices = summaries
1068
                self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
1069
                for meter in self.meters.values {
1070
                    self.restoreChargeMonitoringStateIfNeeded(for: meter)
1071
                }
Bogdan Timofte authored a month ago
1072

            
1073
                self.chargedDevicesReloadInFlight = false
1074
                if self.chargedDevicesReloadPending {
1075
                    self.reloadChargedDevices()
1076
                }
Bogdan Timofte authored a month ago
1077
            }
Bogdan Timofte authored a month ago
1078
        }
1079
    }
1080

            
1081
    private func meter(for meterMACAddress: String) -> Meter? {
1082
        meters.values.first { meter in
1083
            meter.btSerial.macAddress.description == meterMACAddress
1084
        }
1085
    }
1086

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

            
1099
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
1100
                meter.reloadTemperatureUnitPreference()
1101
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
1102
                    didUpdateAnyMeter = true
1103
                }
1104
            }
1105

            
1106
            if didUpdateAnyMeter {
1107
                self.scheduleObjectWillChange()
1108
            }
1109
        }
1110
    }
Bogdan Timofte authored a month ago
1111

            
Bogdan Timofte authored a month ago
1112
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
1113
        guard let charger = chargedDeviceSummary(id: session.chargerID),
1114
              let statistics = session.statistics else {
1115
            return
1116
        }
1117

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

            
1124
        let request = UNNotificationRequest(
1125
            identifier: "charger-standby-\(session.id.uuidString)",
1126
            content: content,
1127
            trigger: nil
1128
        )
1129
        UNUserNotificationCenter.current().add(request)
1130
        scheduleObjectWillChange()
1131
    }
1132

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

            
1142
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
1143
            if lhs.timestamp != rhs.timestamp {
1144
                return lhs.timestamp < rhs.timestamp
1145
            }
1146
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1147
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1148
            }
1149
            return lhs.id.uuidString < rhs.id.uuidString
1150
        }
1151

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

            
Bogdan Timofte authored a month ago
1160
        let effectiveEnergyWh = effectiveEnergyWhOverride
1161
            ?? session.effectiveBatteryEnergyWh
1162
            ?? session.measuredEnergyWh
1163

            
1164
        if let lastCheckpoint = sortedCheckpoints.last,
1165
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
1166
            let estimatedCapacityWh = session.capacityEstimateWh
1167
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1168
                ?? chargedDevice.estimatedBatteryCapacityWh
1169

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

            
1184
                let direction = predictionGap > 0 ? "above" : "below"
1185
                let gapText = abs(predictionGap).format(decimalDigits: 0)
1186
                let expectedText = expectedPercent.format(decimalDigits: 0)
1187

            
1188
                return BatteryCheckpointPlausibilityWarning(
1189
                    title: "Checkpoint Looks Implausible",
1190
                    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."
1191
                )
1192
            }
1193
        }
1194

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

            
1204
        let predictionGap = percent - prediction.predictedPercent
1205
        guard abs(predictionGap) >= 4 else {
1206
            return nil
1207
        }
1208

            
1209
        let direction = predictionGap > 0 ? "above" : "below"
1210
        let gapText = abs(predictionGap).format(decimalDigits: 0)
1211
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
1212

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

            
1221
        return BatteryCheckpointPlausibilityWarning(
1222
            title: "Checkpoint Looks Implausible",
1223
            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."
1224
        )
1225
    }
Bogdan Timofte authored a month ago
1226

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

            
1236
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1237
            return storedEnergyWh
1238
        }
1239

            
1240
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1241
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
1242
        }
1243

            
1244
        return storedEnergyWh
1245
    }
1246

            
Bogdan Timofte authored 2 months ago
1247
}
Bogdan Timofte authored 2 months ago
1248

            
Bogdan Timofte authored a month ago
1249

            
Bogdan Timofte authored 2 months ago
1250
extension AppData.MeterSummary {
1251
    var tint: Color {
1252
        switch modelSummary {
1253
        case "UM25C":
1254
            return .blue
1255
        case "UM34C":
1256
            return .yellow
1257
        case "TC66C":
1258
            return Model.TC66C.color
1259
        default:
1260
            return .secondary
1261
        }
1262
    }
1263
}
Bogdan Timofte authored 2 months ago
1264

            
Bogdan Timofte authored a month ago
1265
extension AppData {
Bogdan Timofte authored 2 months ago
1266
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
1267
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
1268
            return liveName
1269
        }
1270
        if let customName = record?.customName {
1271
            return customName
1272
        }
1273
        if let advertisedName = record?.advertisedName {
1274
            return advertisedName
1275
        }
1276
        if let recordModel = record?.modelName {
1277
            return recordModel
1278
        }
1279
        if let liveModel = liveMeter?.deviceModelSummary {
1280
            return liveModel
1281
        }
1282
        return "Meter"
1283
    }
Bogdan Timofte authored a month ago
1284

            
1285
    static func normalizedMACAddress(_ macAddress: String) -> String {
1286
        macAddress
1287
            .trimmingCharacters(in: .whitespacesAndNewlines)
1288
            .uppercased()
1289
    }
1290

            
1291
    static func isValidMACAddress(_ macAddress: String) -> Bool {
1292
        macAddress.range(
1293
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
1294
            options: .regularExpression
1295
        ) != nil
1296
    }
1297
}
1298

            
1299
private final class ChargeNotificationCoordinator {
1300
    private struct Payload {
1301
        let id: String
1302
        let title: String
1303
        let body: String
1304
        let threadIdentifier: String
1305
    }
1306

            
1307
    private let notificationCenter = UNUserNotificationCenter.current()
1308
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
1309
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
1310
    private var inFlightEventIDs: Set<String> = []
1311

            
1312
    func ensureAuthorizationIfNeeded() {
1313
        notificationCenter.getNotificationSettings { [weak self] settings in
1314
            guard settings.authorizationStatus == .notDetermined else {
1315
                return
1316
            }
1317

            
1318
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
1319
                if let error {
1320
                    track("Notification authorization request failed: \(error.localizedDescription)")
1321
                }
1322
            }
1323
        }
1324
    }
1325

            
1326
    func process(chargedDevices: [ChargedDeviceSummary]) {
1327
        let now = Date()
1328
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
1329
            payloads(for: chargedDevice, now: now)
1330
        }
1331

            
1332
        for payload in pendingPayloads {
1333
            scheduleIfNeeded(payload)
1334
        }
1335
    }
1336

            
1337
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1338
        chargedDevice.sessions.compactMap { session in
1339
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1340
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1341
               let targetBatteryPercent = session.targetBatteryPercent {
1342
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1343
                    ?? session.endBatteryPercent
1344
                    ?? targetBatteryPercent
1345

            
1346
                return Payload(
1347
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1348
                    title: "Battery target reached",
1349
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1350
                    threadIdentifier: session.id.uuidString
1351
                )
1352
            }
1353

            
1354
            if session.requiresCompletionConfirmation,
1355
               let requestedAt = session.completionConfirmationRequestedAt,
1356
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
1357
                let estimatedPercent = session.completionContradictionPercent
1358
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1359
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
1360
                let detail = estimatedPercent.map {
1361
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
1362
                } ?? ""
1363

            
1364
                return Payload(
1365
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1366
                    title: "Confirm charge completion",
1367
                    body: bodyPrefix + detail,
1368
                    threadIdentifier: session.id.uuidString
1369
                )
1370
            }
1371

            
1372
            return nil
1373
        }
1374
    }
1375

            
1376
    private func scheduleIfNeeded(_ payload: Payload) {
1377
        guard deliveredEventIDs().contains(payload.id) == false else {
1378
            return
1379
        }
1380

            
1381
        guard inFlightEventIDs.contains(payload.id) == false else {
1382
            return
1383
        }
1384

            
1385
        inFlightEventIDs.insert(payload.id)
1386

            
1387
        notificationCenter.getNotificationSettings { [weak self] settings in
1388
            guard let self else { return }
1389
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1390
                DispatchQueue.main.async {
1391
                    self.inFlightEventIDs.remove(payload.id)
1392
                }
1393
                return
1394
            }
1395

            
1396
            let content = UNMutableNotificationContent()
1397
            content.title = payload.title
1398
            content.body = payload.body
1399
            content.sound = .default
1400
            content.threadIdentifier = payload.threadIdentifier
1401

            
1402
            let request = UNNotificationRequest(
1403
                identifier: payload.id,
1404
                content: content,
1405
                trigger: nil
1406
            )
1407

            
1408
            self.notificationCenter.add(request) { error in
1409
                DispatchQueue.main.async {
1410
                    self.inFlightEventIDs.remove(payload.id)
1411
                    if let error {
1412
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1413
                        return
1414
                    }
1415
                    self.storeDeliveredEventID(payload.id)
1416
                }
1417
            }
1418
        }
1419
    }
1420

            
1421
    private func deliveredEventIDs() -> Set<String> {
1422
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1423
        return Set(values)
1424
    }
1425

            
1426
    private func storeDeliveredEventID(_ id: String) {
1427
        var values = deliveredEventIDs()
1428
        values.insert(id)
1429
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1430
    }
Bogdan Timofte authored 2 months ago
1431
}