Newer Older
1440 lines | 52.936kb
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 commitSessionTrim(sessionID: UUID) -> Bool {
736
        let didSave = chargeInsightsStore?.commitSessionTrim(sessionID: sessionID) ?? false
737
        if didSave {
738
            reloadChargedDevices()
739
        }
740
        return didSave
741
    }
742

            
Bogdan Timofte authored a month ago
743
    @discardableResult
744
    func flushChargeInsights() -> Bool {
Bogdan Timofte authored a month ago
745
        let didFlushObservations = flushAllPendingChargeObservations()
Bogdan Timofte authored a month ago
746
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
Bogdan Timofte authored a month ago
747
        if didFlushObservations || didSave {
748
            reloadChargedDevices()
749
        }
750
        return didFlushObservations || didSave
Bogdan Timofte authored a month ago
751
    }
752

            
753
    @discardableResult
754
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
755
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
756
            return false
757
        }
758
        return setTargetBatteryPercent(percent, for: activeSession.id)
759
    }
760

            
761
    @discardableResult
762
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
763
        if percent != nil {
764
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
765
        }
766

            
767
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
768
        if didSave {
769
            reloadChargedDevices()
770
        }
771
        return didSave
772
    }
773

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

            
783
    @discardableResult
784
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
785
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
786
        if didSave {
787
            reloadChargedDevices()
788
        }
789
        return didSave
790
    }
791

            
792
    @discardableResult
793
    func deleteChargeSession(sessionID: UUID) -> Bool {
794
        let deletedSession = chargedDevices
795
            .flatMap(\.sessions)
796
            .first(where: { $0.id == sessionID })
797

            
798
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
799
        guard didDelete else {
800
            return false
801
        }
802

            
Bogdan Timofte authored a month ago
803
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
804
           let meterMACAddress = deletedSession?.meterMACAddress,
805
           let liveMeter = meter(for: meterMACAddress) {
806
            liveMeter.resetChargeRecord()
807
        }
808

            
809
        reloadChargedDevices()
810
        return true
811
    }
812

            
813
    @discardableResult
814
    func deleteChargedDevice(id: UUID) -> Bool {
815
        let deletedDevice = chargedDeviceSummary(id: id)
816
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
817
        guard didDelete else {
818
            return false
819
        }
820

            
Bogdan Timofte authored a month ago
821
        if deletedDevice?.isCharger == true {
822
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
823
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
824
                session.stop()
825
                activeChargerStandbySessions[meterMACAddress] = nil
826
            }
827
        }
828

            
Bogdan Timofte authored a month ago
829
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
830
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
831
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
832
           let liveMeter = meter(for: meterMACAddress) {
833
            liveMeter.resetChargeRecord()
834
        }
835

            
836
        reloadChargedDevices()
837
        return true
838
    }
839

            
840
    @discardableResult
841
    func createKnownMeter(
842
        macAddress: String,
843
        customName: String?,
844
        modelName: String,
845
        advertisedName: String?
846
    ) -> Bool {
847
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
848
        guard Self.isValidMACAddress(normalizedMAC) else {
849
            return false
850
        }
851

            
852
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
853
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
854
            setMeterName(customName, for: normalizedMAC)
855
        }
856
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
857
        return true
858
    }
859

            
860
    @discardableResult
861
    func deleteMeter(macAddress: String) -> Bool {
862
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
863
        guard Self.isValidMACAddress(normalizedMAC) else {
864
            return false
865
        }
866

            
867
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
868
            meter.disconnect()
869
        }
870
        meters = meters.filter { element in
871
            element.value.btSerial.macAddress.description != normalizedMAC
872
        }
873

            
874
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
875
        if didDelete {
876
            scheduleObjectWillChange()
877
        }
878
        return didDelete
879
    }
880

            
Bogdan Timofte authored 2 months ago
881
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored a month ago
882
        if let meterSummariesCache, meterSummariesCache.version == meterSummariesVersion {
883
            return meterSummariesCache.summaries
884
        }
885

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

            
Bogdan Timofte authored a month ago
890
        let summaries = macAddresses.map { macAddress in
Bogdan Timofte authored 2 months ago
891
            let liveMeter = liveMetersByMAC[macAddress]
892
            let record = recordsByMAC[macAddress]
893

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

            
918
        meterSummariesCache = (version: meterSummariesVersion, summaries: summaries)
919
        return summaries
Bogdan Timofte authored 2 months ago
920
    }
921

            
Bogdan Timofte authored 2 months ago
922
    private func scheduleObjectWillChange() {
923
        DispatchQueue.main.async { [weak self] in
924
            self?.objectWillChange.send()
925
        }
926
    }
Bogdan Timofte authored 2 months ago
927

            
Bogdan Timofte authored a month ago
928
    private func invalidateMeterSummaries() {
929
        meterSummariesVersion += 1
930
        meterSummariesCache = nil
931
    }
932

            
Bogdan Timofte authored a month ago
933
    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
934
        pendingChargedDevicesReloadWorkItem?.cancel()
935

            
936
        let workItem = DispatchWorkItem { [weak self] in
937
            self?.reloadChargedDevices()
938
        }
939
        pendingChargedDevicesReloadWorkItem = workItem
940
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
941
    }
942

            
Bogdan Timofte authored a month ago
943
    private func stageChargeObservation(_ snapshot: ChargingMonitorSnapshot, scheduleFlush: Bool = true) {
944
        let normalizedMAC = Self.normalizedMACAddress(snapshot.meterMACAddress)
945
        guard !normalizedMAC.isEmpty else {
946
            return
947
        }
948

            
949
        pendingChargeObservationSnapshots[normalizedMAC] = snapshot
950

            
951
        guard scheduleFlush else {
952
            return
953
        }
954

            
955
        guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
956
            return
957
        }
958

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

            
975
    @discardableResult
976
    private func persistChargeSnapshot(from meter: Meter, observedAt: Date = Date()) -> Bool {
977
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
978
            return false
979
        }
980

            
981
        stageChargeObservation(snapshot, scheduleFlush: false)
982
        return flushPendingChargeObservation(for: snapshot.meterMACAddress)
983
    }
984

            
985
    @discardableResult
986
    private func flushPendingChargeObservation(for meterMACAddress: String) -> Bool {
987
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
988
        guard !normalizedMAC.isEmpty else {
989
            return false
990
        }
991

            
992
        pendingChargeObservationWorkItems[normalizedMAC]?.cancel()
993
        pendingChargeObservationWorkItems[normalizedMAC] = nil
994

            
995
        guard let snapshot = pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
996
            return false
997
        }
998

            
999
        let didSave = chargeInsightsStore?.observe(snapshot: snapshot) ?? false
1000
        return didSave
1001
    }
1002

            
1003
    @discardableResult
1004
    private func flushAllPendingChargeObservations() -> Bool {
1005
        let pendingMeterMACAddresses = Array(pendingChargeObservationSnapshots.keys)
1006
        var didSave = false
1007

            
1008
        for meterMACAddress in pendingMeterMACAddresses {
1009
            if flushPendingChargeObservation(for: meterMACAddress) {
1010
                didSave = true
1011
            }
1012
        }
1013

            
1014
        return didSave
1015
    }
1016

            
Bogdan Timofte authored a month ago
1017
    private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
1018
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
1019
        guard !normalizedMAC.isEmpty else {
1020
            return nil
1021
        }
1022

            
1023
        return chargedDevices
1024
            .lazy
1025
            .compactMap(\.activeSession)
Bogdan Timofte authored a month ago
1026
            .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
1027
    }
1028

            
Bogdan Timofte authored a month ago
1029
    @discardableResult
1030
    private func healDuplicateOpenSessions() -> Bool {
1031
        chargeInsightsStore?.healDuplicateOpenSessions() ?? false
1032
    }
1033

            
Bogdan Timofte authored a month ago
1034
    @discardableResult
1035
    private func expireOverlongChargeSessionsIfNeeded(referenceDate: Date = Date()) -> Bool {
1036
        chargeInsightsStore?.completeExpiredOpenSessions(referenceDate: referenceDate) ?? false
1037
    }
1038

            
Bogdan Timofte authored a month ago
1039
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
1040
        if Thread.isMainThread == false {
1041
            DispatchQueue.main.async { [weak self] in
1042
                self?.reloadChargedDevices()
1043
            }
1044
            return
1045
        }
1046

            
Bogdan Timofte authored a month ago
1047
        pendingChargedDevicesReloadWorkItem?.cancel()
1048
        pendingChargedDevicesReloadWorkItem = nil
1049

            
Bogdan Timofte authored a month ago
1050
        _ = healDuplicateOpenSessions()
Bogdan Timofte authored a month ago
1051
        _ = expireOverlongChargeSessionsIfNeeded()
1052

            
Bogdan Timofte authored a month ago
1053
        guard chargedDevicesReloadInFlight == false else {
1054
            chargedDevicesReloadPending = true
1055
            return
1056
        }
1057

            
Bogdan Timofte authored a month ago
1058
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
Bogdan Timofte authored a month ago
1059
        let readStore = chargeInsightsReadStore ?? chargeInsightsStore
Bogdan Timofte authored a month ago
1060
        chargedDevicesReloadInFlight = true
1061
        chargedDevicesReloadPending = false
Bogdan Timofte authored a month ago
1062

            
1063
        chargedDevicesReloadQueue.async { [weak self] in
1064
            guard let self else { return }
1065

            
1066
            readStore?.resetContext()
1067
            let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
1068
                chargedDevice.withStandbyPowerMeasurements(
1069
                    standbyMeasurementsByChargerID[chargedDevice.id] ?? []
1070
                )
1071
            }
1072

            
1073
            DispatchQueue.main.async { [weak self] in
1074
                guard let self else { return }
1075

            
1076
                self.chargedDevices = summaries
1077
                self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
1078
                for meter in self.meters.values {
1079
                    self.restoreChargeMonitoringStateIfNeeded(for: meter)
1080
                }
Bogdan Timofte authored a month ago
1081

            
1082
                self.chargedDevicesReloadInFlight = false
1083
                if self.chargedDevicesReloadPending {
1084
                    self.reloadChargedDevices()
1085
                }
Bogdan Timofte authored a month ago
1086
            }
Bogdan Timofte authored a month ago
1087
        }
1088
    }
1089

            
1090
    private func meter(for meterMACAddress: String) -> Meter? {
1091
        meters.values.first { meter in
1092
            meter.btSerial.macAddress.description == meterMACAddress
1093
        }
1094
    }
1095

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

            
1108
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
1109
                meter.reloadTemperatureUnitPreference()
1110
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
1111
                    didUpdateAnyMeter = true
1112
                }
1113
            }
1114

            
1115
            if didUpdateAnyMeter {
1116
                self.scheduleObjectWillChange()
1117
            }
1118
        }
1119
    }
Bogdan Timofte authored a month ago
1120

            
Bogdan Timofte authored a month ago
1121
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
1122
        guard let charger = chargedDeviceSummary(id: session.chargerID),
1123
              let statistics = session.statistics else {
1124
            return
1125
        }
1126

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

            
1133
        let request = UNNotificationRequest(
1134
            identifier: "charger-standby-\(session.id.uuidString)",
1135
            content: content,
1136
            trigger: nil
1137
        )
1138
        UNUserNotificationCenter.current().add(request)
1139
        scheduleObjectWillChange()
1140
    }
1141

            
Bogdan Timofte authored a month ago
1142
    private func batteryCheckpointPlausibilityWarning(
1143
        percent: Double,
Bogdan Timofte authored a month ago
1144
        for session: ChargeSessionSummary,
1145
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
1146
    ) -> BatteryCheckpointPlausibilityWarning? {
1147
        guard percent.isFinite, percent >= 0, percent <= 100 else {
1148
            return nil
1149
        }
1150

            
1151
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
1152
            if lhs.timestamp != rhs.timestamp {
1153
                return lhs.timestamp < rhs.timestamp
1154
            }
1155
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1156
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1157
            }
1158
            return lhs.id.uuidString < rhs.id.uuidString
1159
        }
1160

            
1161
        if let lastCheckpoint = sortedCheckpoints.last,
1162
           percent < lastCheckpoint.batteryPercent - 1.5 {
1163
            return BatteryCheckpointPlausibilityWarning(
1164
                title: "Checkpoint Goes Backwards",
1165
                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."
1166
            )
1167
        }
1168

            
Bogdan Timofte authored a month ago
1169
        let effectiveEnergyWh = effectiveEnergyWhOverride
1170
            ?? session.effectiveBatteryEnergyWh
1171
            ?? session.measuredEnergyWh
1172

            
1173
        if let lastCheckpoint = sortedCheckpoints.last,
1174
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
1175
            let estimatedCapacityWh = session.capacityEstimateWh
1176
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1177
                ?? chargedDevice.estimatedBatteryCapacityWh
1178

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

            
1193
                let direction = predictionGap > 0 ? "above" : "below"
1194
                let gapText = abs(predictionGap).format(decimalDigits: 0)
1195
                let expectedText = expectedPercent.format(decimalDigits: 0)
1196

            
1197
                return BatteryCheckpointPlausibilityWarning(
1198
                    title: "Checkpoint Looks Implausible",
1199
                    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."
1200
                )
1201
            }
1202
        }
1203

            
Bogdan Timofte authored a month ago
1204
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
Bogdan Timofte authored a month ago
1205
              let prediction = chargedDevice.batteryLevelPrediction(
1206
                for: session,
1207
                effectiveEnergyWhOverride: effectiveEnergyWh
1208
              )
Bogdan Timofte authored a month ago
1209
        else {
1210
            return nil
1211
        }
1212

            
1213
        let predictionGap = percent - prediction.predictedPercent
1214
        guard abs(predictionGap) >= 4 else {
1215
            return nil
1216
        }
1217

            
1218
        let direction = predictionGap > 0 ? "above" : "below"
1219
        let gapText = abs(predictionGap).format(decimalDigits: 0)
1220
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
1221

            
1222
        if let lastCheckpoint = sortedCheckpoints.last {
1223
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1224
            return BatteryCheckpointPlausibilityWarning(
1225
                title: "Checkpoint Looks Implausible",
1226
                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."
1227
            )
1228
        }
1229

            
1230
        return BatteryCheckpointPlausibilityWarning(
1231
            title: "Checkpoint Looks Implausible",
1232
            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."
1233
        )
1234
    }
Bogdan Timofte authored a month ago
1235

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

            
1245
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1246
            return storedEnergyWh
1247
        }
1248

            
1249
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1250
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
1251
        }
1252

            
1253
        return storedEnergyWh
1254
    }
1255

            
Bogdan Timofte authored 2 months ago
1256
}
Bogdan Timofte authored 2 months ago
1257

            
Bogdan Timofte authored a month ago
1258

            
Bogdan Timofte authored 2 months ago
1259
extension AppData.MeterSummary {
1260
    var tint: Color {
1261
        switch modelSummary {
1262
        case "UM25C":
1263
            return .blue
1264
        case "UM34C":
1265
            return .yellow
1266
        case "TC66C":
1267
            return Model.TC66C.color
1268
        default:
1269
            return .secondary
1270
        }
1271
    }
1272
}
Bogdan Timofte authored 2 months ago
1273

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

            
1294
    static func normalizedMACAddress(_ macAddress: String) -> String {
1295
        macAddress
1296
            .trimmingCharacters(in: .whitespacesAndNewlines)
1297
            .uppercased()
1298
    }
1299

            
1300
    static func isValidMACAddress(_ macAddress: String) -> Bool {
1301
        macAddress.range(
1302
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
1303
            options: .regularExpression
1304
        ) != nil
1305
    }
1306
}
1307

            
1308
private final class ChargeNotificationCoordinator {
1309
    private struct Payload {
1310
        let id: String
1311
        let title: String
1312
        let body: String
1313
        let threadIdentifier: String
1314
    }
1315

            
1316
    private let notificationCenter = UNUserNotificationCenter.current()
1317
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
1318
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
1319
    private var inFlightEventIDs: Set<String> = []
1320

            
1321
    func ensureAuthorizationIfNeeded() {
1322
        notificationCenter.getNotificationSettings { [weak self] settings in
1323
            guard settings.authorizationStatus == .notDetermined else {
1324
                return
1325
            }
1326

            
1327
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
1328
                if let error {
1329
                    track("Notification authorization request failed: \(error.localizedDescription)")
1330
                }
1331
            }
1332
        }
1333
    }
1334

            
1335
    func process(chargedDevices: [ChargedDeviceSummary]) {
1336
        let now = Date()
1337
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
1338
            payloads(for: chargedDevice, now: now)
1339
        }
1340

            
1341
        for payload in pendingPayloads {
1342
            scheduleIfNeeded(payload)
1343
        }
1344
    }
1345

            
1346
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1347
        chargedDevice.sessions.compactMap { session in
1348
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1349
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1350
               let targetBatteryPercent = session.targetBatteryPercent {
1351
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1352
                    ?? session.endBatteryPercent
1353
                    ?? targetBatteryPercent
1354

            
1355
                return Payload(
1356
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1357
                    title: "Battery target reached",
1358
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1359
                    threadIdentifier: session.id.uuidString
1360
                )
1361
            }
1362

            
1363
            if session.requiresCompletionConfirmation,
1364
               let requestedAt = session.completionConfirmationRequestedAt,
1365
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
1366
                let estimatedPercent = session.completionContradictionPercent
1367
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1368
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
1369
                let detail = estimatedPercent.map {
1370
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
1371
                } ?? ""
1372

            
1373
                return Payload(
1374
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1375
                    title: "Confirm charge completion",
1376
                    body: bodyPrefix + detail,
1377
                    threadIdentifier: session.id.uuidString
1378
                )
1379
            }
1380

            
1381
            return nil
1382
        }
1383
    }
1384

            
1385
    private func scheduleIfNeeded(_ payload: Payload) {
1386
        guard deliveredEventIDs().contains(payload.id) == false else {
1387
            return
1388
        }
1389

            
1390
        guard inFlightEventIDs.contains(payload.id) == false else {
1391
            return
1392
        }
1393

            
1394
        inFlightEventIDs.insert(payload.id)
1395

            
1396
        notificationCenter.getNotificationSettings { [weak self] settings in
1397
            guard let self else { return }
1398
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1399
                DispatchQueue.main.async {
1400
                    self.inFlightEventIDs.remove(payload.id)
1401
                }
1402
                return
1403
            }
1404

            
1405
            let content = UNMutableNotificationContent()
1406
            content.title = payload.title
1407
            content.body = payload.body
1408
            content.sound = .default
1409
            content.threadIdentifier = payload.threadIdentifier
1410

            
1411
            let request = UNNotificationRequest(
1412
                identifier: payload.id,
1413
                content: content,
1414
                trigger: nil
1415
            )
1416

            
1417
            self.notificationCenter.add(request) { error in
1418
                DispatchQueue.main.async {
1419
                    self.inFlightEventIDs.remove(payload.id)
1420
                    if let error {
1421
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1422
                        return
1423
                    }
1424
                    self.storeDeliveredEventID(payload.id)
1425
                }
1426
            }
1427
        }
1428
    }
1429

            
1430
    private func deliveredEventIDs() -> Set<String> {
1431
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1432
        return Set(values)
1433
    }
1434

            
1435
    private func storeDeliveredEventID(_ id: String) {
1436
        var values = deliveredEventIDs()
1437
        values.insert(id)
1438
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1439
    }
Bogdan Timofte authored 2 months ago
1440
}