Newer Older
1447 lines | 53.38kb
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 expireOverlongChargeSessionsIfNeeded(referenceDate: Date = Date()) -> Bool {
1026
        chargeInsightsStore?.completeExpiredOpenSessions(referenceDate: referenceDate) ?? false
1027
    }
1028

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

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

            
Bogdan Timofte authored a month ago
1040
        _ = expireOverlongChargeSessionsIfNeeded()
1041

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

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

            
1052
        chargedDevicesReloadQueue.async { [weak self] in
1053
            guard let self else { return }
1054

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
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). Save it only if the device gauge really jumped that much."
1222
        )
1223
    }
Bogdan Timofte authored a month ago
1224

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

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

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

            
1242
        return storedEnergyWh
1243
    }
1244

            
1245
    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1246
        let storedChargeAh = session.measuredChargeAh
Bogdan Timofte authored a month ago
1247
        guard session.isTrimmed == false else {
1248
            return storedChargeAh
1249
        }
Bogdan Timofte authored a month ago
1250
        guard session.status.isOpen else {
1251
            return storedChargeAh
1252
        }
1253

            
1254
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1255
            return storedChargeAh
1256
        }
1257

            
1258
        if let baselineChargeAh = session.meterChargeBaselineAh {
1259
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
1260
        }
1261

            
1262
        return storedChargeAh
1263
    }
Bogdan Timofte authored 2 months ago
1264
}
Bogdan Timofte authored 2 months ago
1265

            
1266
extension AppData.MeterSummary {
1267
    var tint: Color {
1268
        switch modelSummary {
1269
        case "UM25C":
1270
            return .blue
1271
        case "UM34C":
1272
            return .yellow
1273
        case "TC66C":
1274
            return Model.TC66C.color
1275
        default:
1276
            return .secondary
1277
        }
1278
    }
1279
}
Bogdan Timofte authored 2 months ago
1280

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

            
1301
    static func normalizedMACAddress(_ macAddress: String) -> String {
1302
        macAddress
1303
            .trimmingCharacters(in: .whitespacesAndNewlines)
1304
            .uppercased()
1305
    }
1306

            
1307
    static func isValidMACAddress(_ macAddress: String) -> Bool {
1308
        macAddress.range(
1309
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
1310
            options: .regularExpression
1311
        ) != nil
1312
    }
1313
}
1314

            
1315
private final class ChargeNotificationCoordinator {
1316
    private struct Payload {
1317
        let id: String
1318
        let title: String
1319
        let body: String
1320
        let threadIdentifier: String
1321
    }
1322

            
1323
    private let notificationCenter = UNUserNotificationCenter.current()
1324
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
1325
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
1326
    private var inFlightEventIDs: Set<String> = []
1327

            
1328
    func ensureAuthorizationIfNeeded() {
1329
        notificationCenter.getNotificationSettings { [weak self] settings in
1330
            guard settings.authorizationStatus == .notDetermined else {
1331
                return
1332
            }
1333

            
1334
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
1335
                if let error {
1336
                    track("Notification authorization request failed: \(error.localizedDescription)")
1337
                }
1338
            }
1339
        }
1340
    }
1341

            
1342
    func process(chargedDevices: [ChargedDeviceSummary]) {
1343
        let now = Date()
1344
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
1345
            payloads(for: chargedDevice, now: now)
1346
        }
1347

            
1348
        for payload in pendingPayloads {
1349
            scheduleIfNeeded(payload)
1350
        }
1351
    }
1352

            
1353
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1354
        chargedDevice.sessions.compactMap { session in
1355
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1356
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1357
               let targetBatteryPercent = session.targetBatteryPercent {
1358
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1359
                    ?? session.endBatteryPercent
1360
                    ?? targetBatteryPercent
1361

            
1362
                return Payload(
1363
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1364
                    title: "Battery target reached",
1365
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1366
                    threadIdentifier: session.id.uuidString
1367
                )
1368
            }
1369

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

            
1380
                return Payload(
1381
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1382
                    title: "Confirm charge completion",
1383
                    body: bodyPrefix + detail,
1384
                    threadIdentifier: session.id.uuidString
1385
                )
1386
            }
1387

            
1388
            return nil
1389
        }
1390
    }
1391

            
1392
    private func scheduleIfNeeded(_ payload: Payload) {
1393
        guard deliveredEventIDs().contains(payload.id) == false else {
1394
            return
1395
        }
1396

            
1397
        guard inFlightEventIDs.contains(payload.id) == false else {
1398
            return
1399
        }
1400

            
1401
        inFlightEventIDs.insert(payload.id)
1402

            
1403
        notificationCenter.getNotificationSettings { [weak self] settings in
1404
            guard let self else { return }
1405
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1406
                DispatchQueue.main.async {
1407
                    self.inFlightEventIDs.remove(payload.id)
1408
                }
1409
                return
1410
            }
1411

            
1412
            let content = UNMutableNotificationContent()
1413
            content.title = payload.title
1414
            content.body = payload.body
1415
            content.sound = .default
1416
            content.threadIdentifier = payload.threadIdentifier
1417

            
1418
            let request = UNNotificationRequest(
1419
                identifier: payload.id,
1420
                content: content,
1421
                trigger: nil
1422
            )
1423

            
1424
            self.notificationCenter.add(request) { error in
1425
                DispatchQueue.main.async {
1426
                    self.inFlightEventIDs.remove(payload.id)
1427
                    if let error {
1428
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1429
                        return
1430
                    }
1431
                    self.storeDeliveredEventID(payload.id)
1432
                }
1433
            }
1434
        }
1435
    }
1436

            
1437
    private func deliveredEventIDs() -> Set<String> {
1438
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1439
        return Set(values)
1440
    }
1441

            
1442
    private func storeDeliveredEventID(_ id: String) {
1443
        var values = deliveredEventIDs()
1444
        values.insert(id)
1445
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1446
    }
Bogdan Timofte authored 2 months ago
1447
}