Newer Older
1431 lines | 52.677kb
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
            }
231
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
232
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
233
        }
234
    }
235

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

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

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

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

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

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

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

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

            
281
        if let cachedSummary = cachedActiveChargeSessionSummary(for: normalizedMAC) {
Bogdan Timofte authored a month ago
282
            return cachedSummary
283
        }
Bogdan Timofte authored a month ago
284
        return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC)
Bogdan Timofte authored a month ago
285
    }
286

            
Bogdan Timofte authored a month ago
287
    func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
288
        activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)]
289
    }
290

            
291
    @discardableResult
292
    func startChargerStandbyMeasurement(for chargerID: UUID, on meter: Meter) -> Bool {
293
        guard chargedDeviceSummary(id: chargerID)?.isCharger == true else {
294
            return false
295
        }
296

            
297
        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
298
        if let existingSession = activeChargerStandbySessions[normalizedMAC] {
299
            return existingSession.chargerID == chargerID
300
        }
301

            
302
        let session = ChargerStandbyPowerMonitorSession(chargerID: chargerID, meter: meter)
303
        session.onChange = { [weak self] in
304
            self?.scheduleObjectWillChange()
305
        }
306
        session.onStabilized = { [weak self, weak session] in
307
            guard let self, let session else { return }
308
            self.notifyChargerStandbyMeasurementReady(for: session)
309
        }
310

            
311
        activeChargerStandbySessions[normalizedMAC] = session
312
        session.start()
313

            
314
        // Starting a standby run on an available meter should also initiate the BLE link.
315
        if meter.operationalState == .peripheralNotConnected {
316
            meter.connect()
317
        }
318

            
319
        scheduleObjectWillChange()
320
        return true
321
    }
322

            
323
    @discardableResult
324
    func finishChargerStandbyMeasurement(for meterMACAddress: String, save: Bool) -> Bool {
325
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
326
        guard let session = activeChargerStandbySessions[normalizedMAC] else {
327
            return false
328
        }
329

            
330
        session.stop()
331

            
332
        guard save else {
333
            activeChargerStandbySessions[normalizedMAC] = nil
334
            scheduleObjectWillChange()
335
            return true
336
        }
337

            
338
        guard let summary = session.makeSummary() else {
339
            scheduleObjectWillChange()
340
            return false
341
        }
342

            
343
        let didSave = chargerStandbyPowerStore.save(summary)
344
        if didSave {
345
            activeChargerStandbySessions[normalizedMAC] = nil
346
            reloadChargedDevices()
347
        } else {
348
            scheduleObjectWillChange()
349
        }
350

            
351
        return didSave
352
    }
353

            
Bogdan Timofte authored a month ago
354
    @discardableResult
355
    func deleteChargerStandbyMeasurement(id: UUID, chargerID: UUID) -> Bool {
356
        let didDelete = chargerStandbyPowerStore.removeMeasurement(id: id, chargerID: chargerID)
357
        if didDelete {
358
            reloadChargedDevices()
359
        } else {
360
            scheduleObjectWillChange()
361
        }
362
        return didDelete
363
    }
364

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

            
391
        if didSave {
392
            reloadChargedDevices()
393
        }
394

            
395
        return didSave
396
    }
397

            
398
    @discardableResult
Bogdan Timofte authored a month ago
399
    func createCharger(
400
        name: String,
Bogdan Timofte authored a month ago
401
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
402
        notes: String?,
403
        meterMACAddress: String?
404
    ) -> Bool {
405
        let didSave = chargeInsightsStore?.createCharger(
406
            name: name,
Bogdan Timofte authored a month ago
407
            chargerType: chargerType,
Bogdan Timofte authored a month ago
408
            notes: notes,
409
            assignTo: meterMACAddress
410
        ) ?? false
411

            
412
        if didSave {
413
            reloadChargedDevices()
414
        }
415

            
416
        return didSave
417
    }
418

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

            
445
        if didSave {
446
            reloadChargedDevices()
447
        }
448

            
449
        return didSave
450
    }
451

            
452
    @discardableResult
Bogdan Timofte authored a month ago
453
    func updateCharger(
454
        id: UUID,
455
        name: String,
Bogdan Timofte authored a month ago
456
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
457
        notes: String?
458
    ) -> Bool {
459
        let didSave = chargeInsightsStore?.updateCharger(
460
            id: id,
461
            name: name,
Bogdan Timofte authored a month ago
462
            chargerType: chargerType,
Bogdan Timofte authored a month ago
463
            notes: notes
Bogdan Timofte authored a month ago
464
        ) ?? false
465

            
466
        if didSave {
467
            reloadChargedDevices()
468
        }
469

            
470
        return didSave
471
    }
472

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

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

            
Bogdan Timofte authored a month ago
491
    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
492
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
493
            return
494
        }
495
        guard activeSession.status == .active else {
496
            return
497
        }
498
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
499
    }
500

            
Bogdan Timofte authored a month ago
501
    @discardableResult
Bogdan Timofte authored a month ago
502
    func startChargeSession(
503
        for meter: Meter,
504
        chargedDeviceID: UUID,
505
        chargerID: UUID?,
506
        chargingTransportMode: ChargingTransportMode,
507
        chargingStateMode: ChargingStateMode,
508
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
509
        initialBatteryPercent: Double?,
510
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
511
    ) -> Bool {
Bogdan Timofte authored a month ago
512
        meter.resetMeterCountersForNewSession()
513

            
Bogdan Timofte authored a month ago
514
        guard let snapshot = meter.chargingMonitorSnapshot else {
515
            return false
516
        }
517

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

            
546
    @discardableResult
547
    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
548
        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
Bogdan Timofte authored a month ago
549

            
550
        if let meter {
551
            _ = persistChargeSnapshot(from: meter, observedAt: observedAt)
552
        } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
553
            _ = flushPendingChargeObservation(for: meterMACAddress)
554
        }
555

            
Bogdan Timofte authored a month ago
556
        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
557
        if didSave {
558
            reloadChargedDevices()
559
        }
560
        return didSave
561
    }
562

            
563
    @discardableResult
564
    func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
565
        let snapshot = meter?.chargingMonitorSnapshot
566
        let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
567
        if didSave {
568
            reloadChargedDevices()
569
        }
570
        return didSave
571
    }
572

            
573
    @discardableResult
Bogdan Timofte authored a month ago
574
    func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil) -> Bool {
Bogdan Timofte authored a month ago
575
        if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
576
            _ = flushPendingChargeObservation(for: meterMACAddress)
577
        }
578

            
Bogdan Timofte authored a month ago
579
        let didSave = chargeInsightsStore?.stopSession(
580
            id: sessionID,
Bogdan Timofte authored a month ago
581
            finalBatteryPercent: finalBatteryPercent
Bogdan Timofte authored a month ago
582
        ) ?? false
Bogdan Timofte authored a month ago
583
        reloadChargedDevices()
Bogdan Timofte authored a month ago
584
        return didSave
585
    }
586

            
587
    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
588
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
589
            return
590
        }
591

            
Bogdan Timofte authored a month ago
592
        stageChargeObservation(snapshot)
Bogdan Timofte authored a month ago
593
    }
594

            
595
    @discardableResult
Bogdan Timofte authored a month ago
596
    func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
Bogdan Timofte authored a month ago
597
        _ = persistChargeSnapshot(from: meter)
Bogdan Timofte authored a month ago
598

            
599
        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
600
        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
601
        let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
602

            
Bogdan Timofte authored a month ago
603
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
604
            percent: percent,
Bogdan Timofte authored a month ago
605
            for: meter.btSerial.macAddress.description,
606
            measuredEnergyWh: checkpointEnergyWh,
607
            measuredChargeAh: checkpointChargeAh
Bogdan Timofte authored a month ago
608
        ) ?? false
609

            
610
        if didSave {
611
            reloadChargedDevices()
612
        }
613

            
614
        return didSave
615
    }
616

            
617
    @discardableResult
Bogdan Timofte authored a month ago
618
    func addBatteryCheckpoint(percent: Double, for sessionID: UUID) -> Bool {
619
        guard canAddBatteryCheckpoint(to: sessionID) else {
620
            return false
621
        }
622

            
Bogdan Timofte authored a month ago
623
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
624
            percent: percent,
625
            for: sessionID
626
        ) ?? false
627

            
628
        if didSave {
629
            reloadChargedDevices()
630
        }
631

            
632
        return didSave
633
    }
634

            
Bogdan Timofte authored a month ago
635
    @discardableResult
636
    func addBatteryCheckpoint(
637
        percent: Double,
638
        for sessionID: UUID,
639
        measuredEnergyWh: Double?,
640
        measuredChargeAh: Double?
641
    ) -> Bool {
Bogdan Timofte authored a month ago
642
        guard canAddBatteryCheckpoint(to: sessionID) else {
643
            return false
644
        }
645

            
Bogdan Timofte authored a month ago
646
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
647
            percent: percent,
648
            for: sessionID,
649
            measuredEnergyWh: measuredEnergyWh,
650
            measuredChargeAh: measuredChargeAh
651
        ) ?? false
652

            
653
        if didSave {
654
            reloadChargedDevices()
655
        }
656

            
657
        return didSave
658
    }
659

            
Bogdan Timofte authored a month ago
660
    func canAddBatteryCheckpoint(to sessionID: UUID) -> Bool {
661
        guard let session = chargeSessionSummary(id: sessionID),
662
              session.status.isOpen,
663
              let meterMACAddress = session.meterMACAddress else {
664
            return false
665
        }
666

            
667
        return meter(for: meterMACAddress) != nil
668
    }
669

            
670
    func batteryCheckpointCaptureRequirementMessage(for sessionID: UUID) -> String? {
671
        guard let session = chargeSessionSummary(id: sessionID) else {
672
            return "Battery checkpoints are available only while the charge session is still active."
673
        }
674

            
675
        guard session.status.isOpen else {
676
            return "Battery checkpoints are available only while the charge session is still active."
677
        }
678

            
679
        guard let meterMACAddress = session.meterMACAddress,
680
              meter(for: meterMACAddress) != nil else {
681
            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."
682
        }
683

            
684
        return nil
685
    }
686

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

            
702
    @discardableResult
703
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
704
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
705
            id: checkpointID,
706
            from: sessionID
707
        ) ?? false
708

            
709
        if didDelete {
Bogdan Timofte authored a month ago
710
            reloadChargedDevices()
Bogdan Timofte authored a month ago
711
        }
712

            
713
        return didDelete
714
    }
715

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

            
Bogdan Timofte authored a month ago
729
    @discardableResult
730
    func flushChargeInsights() -> Bool {
Bogdan Timofte authored a month ago
731
        let didFlushObservations = flushAllPendingChargeObservations()
Bogdan Timofte authored a month ago
732
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
Bogdan Timofte authored a month ago
733
        if didFlushObservations || didSave {
734
            reloadChargedDevices()
735
        }
736
        return didFlushObservations || didSave
Bogdan Timofte authored a month ago
737
    }
738

            
739
    @discardableResult
740
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
741
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
742
            return false
743
        }
744
        return setTargetBatteryPercent(percent, for: activeSession.id)
745
    }
746

            
747
    @discardableResult
748
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
749
        if percent != nil {
750
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
751
        }
752

            
753
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
754
        if didSave {
755
            reloadChargedDevices()
756
        }
757
        return didSave
758
    }
759

            
760
    @discardableResult
761
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
762
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
763
        if didSave {
764
            reloadChargedDevices()
765
        }
766
        return didSave
767
    }
768

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

            
778
    @discardableResult
779
    func deleteChargeSession(sessionID: UUID) -> Bool {
780
        let deletedSession = chargedDevices
781
            .flatMap(\.sessions)
782
            .first(where: { $0.id == sessionID })
783

            
784
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
785
        guard didDelete else {
786
            return false
787
        }
788

            
Bogdan Timofte authored a month ago
789
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
790
           let meterMACAddress = deletedSession?.meterMACAddress,
791
           let liveMeter = meter(for: meterMACAddress) {
792
            liveMeter.resetChargeRecord()
793
        }
794

            
795
        reloadChargedDevices()
796
        return true
797
    }
798

            
799
    @discardableResult
800
    func deleteChargedDevice(id: UUID) -> Bool {
801
        let deletedDevice = chargedDeviceSummary(id: id)
802
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
803
        guard didDelete else {
804
            return false
805
        }
806

            
Bogdan Timofte authored a month ago
807
        if deletedDevice?.isCharger == true {
808
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
809
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
810
                session.stop()
811
                activeChargerStandbySessions[meterMACAddress] = nil
812
            }
813
        }
814

            
Bogdan Timofte authored a month ago
815
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
816
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
817
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
818
           let liveMeter = meter(for: meterMACAddress) {
819
            liveMeter.resetChargeRecord()
820
        }
821

            
822
        reloadChargedDevices()
823
        return true
824
    }
825

            
826
    @discardableResult
827
    func createKnownMeter(
828
        macAddress: String,
829
        customName: String?,
830
        modelName: String,
831
        advertisedName: String?
832
    ) -> Bool {
833
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
834
        guard Self.isValidMACAddress(normalizedMAC) else {
835
            return false
836
        }
837

            
838
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
839
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
840
            setMeterName(customName, for: normalizedMAC)
841
        }
842
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
843
        return true
844
    }
845

            
846
    @discardableResult
847
    func deleteMeter(macAddress: String) -> Bool {
848
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
849
        guard Self.isValidMACAddress(normalizedMAC) else {
850
            return false
851
        }
852

            
853
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
854
            meter.disconnect()
855
        }
856
        meters = meters.filter { element in
857
            element.value.btSerial.macAddress.description != normalizedMAC
858
        }
859

            
860
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
861
        if didDelete {
862
            scheduleObjectWillChange()
863
        }
864
        return didDelete
865
    }
866

            
Bogdan Timofte authored 2 months ago
867
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored a month ago
868
        if let meterSummariesCache, meterSummariesCache.version == meterSummariesVersion {
869
            return meterSummariesCache.summaries
870
        }
871

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

            
Bogdan Timofte authored a month ago
876
        let summaries = macAddresses.map { macAddress in
Bogdan Timofte authored 2 months ago
877
            let liveMeter = liveMetersByMAC[macAddress]
878
            let record = recordsByMAC[macAddress]
879

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

            
904
        meterSummariesCache = (version: meterSummariesVersion, summaries: summaries)
905
        return summaries
Bogdan Timofte authored 2 months ago
906
    }
907

            
Bogdan Timofte authored 2 months ago
908
    private func scheduleObjectWillChange() {
909
        DispatchQueue.main.async { [weak self] in
910
            self?.objectWillChange.send()
911
        }
912
    }
Bogdan Timofte authored 2 months ago
913

            
Bogdan Timofte authored a month ago
914
    private func invalidateMeterSummaries() {
915
        meterSummariesVersion += 1
916
        meterSummariesCache = nil
917
    }
918

            
Bogdan Timofte authored a month ago
919
    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
920
        pendingChargedDevicesReloadWorkItem?.cancel()
921

            
922
        let workItem = DispatchWorkItem { [weak self] in
923
            self?.reloadChargedDevices()
924
        }
925
        pendingChargedDevicesReloadWorkItem = workItem
926
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
927
    }
928

            
Bogdan Timofte authored a month ago
929
    private func stageChargeObservation(_ snapshot: ChargingMonitorSnapshot, scheduleFlush: Bool = true) {
930
        let normalizedMAC = Self.normalizedMACAddress(snapshot.meterMACAddress)
931
        guard !normalizedMAC.isEmpty else {
932
            return
933
        }
934

            
935
        pendingChargeObservationSnapshots[normalizedMAC] = snapshot
936

            
937
        guard scheduleFlush else {
938
            return
939
        }
940

            
941
        guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
942
            return
943
        }
944

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

            
961
    @discardableResult
962
    private func persistChargeSnapshot(from meter: Meter, observedAt: Date = Date()) -> Bool {
963
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
964
            return false
965
        }
966

            
967
        stageChargeObservation(snapshot, scheduleFlush: false)
968
        return flushPendingChargeObservation(for: snapshot.meterMACAddress)
969
    }
970

            
971
    @discardableResult
972
    private func flushPendingChargeObservation(for meterMACAddress: String) -> Bool {
973
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
974
        guard !normalizedMAC.isEmpty else {
975
            return false
976
        }
977

            
978
        pendingChargeObservationWorkItems[normalizedMAC]?.cancel()
979
        pendingChargeObservationWorkItems[normalizedMAC] = nil
980

            
981
        guard let snapshot = pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
982
            return false
983
        }
984

            
985
        let didSave = chargeInsightsStore?.observe(snapshot: snapshot) ?? false
986
        return didSave
987
    }
988

            
989
    @discardableResult
990
    private func flushAllPendingChargeObservations() -> Bool {
991
        let pendingMeterMACAddresses = Array(pendingChargeObservationSnapshots.keys)
992
        var didSave = false
993

            
994
        for meterMACAddress in pendingMeterMACAddresses {
995
            if flushPendingChargeObservation(for: meterMACAddress) {
996
                didSave = true
997
            }
998
        }
999

            
1000
        return didSave
1001
    }
1002

            
Bogdan Timofte authored a month ago
1003
    private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
1004
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
1005
        guard !normalizedMAC.isEmpty else {
1006
            return nil
1007
        }
1008

            
1009
        return chargedDevices
1010
            .lazy
1011
            .compactMap(\.activeSession)
Bogdan Timofte authored a month ago
1012
            .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
1013
    }
1014

            
Bogdan Timofte authored a month ago
1015
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
1016
        if Thread.isMainThread == false {
1017
            DispatchQueue.main.async { [weak self] in
1018
                self?.reloadChargedDevices()
1019
            }
1020
            return
1021
        }
1022

            
Bogdan Timofte authored a month ago
1023
        pendingChargedDevicesReloadWorkItem?.cancel()
1024
        pendingChargedDevicesReloadWorkItem = nil
1025

            
Bogdan Timofte authored a month ago
1026
        guard chargedDevicesReloadInFlight == false else {
1027
            chargedDevicesReloadPending = true
1028
            return
1029
        }
1030

            
Bogdan Timofte authored a month ago
1031
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
Bogdan Timofte authored a month ago
1032
        let readStore = chargeInsightsReadStore ?? chargeInsightsStore
Bogdan Timofte authored a month ago
1033
        chargedDevicesReloadInFlight = true
1034
        chargedDevicesReloadPending = false
Bogdan Timofte authored a month ago
1035

            
1036
        chargedDevicesReloadQueue.async { [weak self] in
1037
            guard let self else { return }
1038

            
1039
            readStore?.resetContext()
1040
            let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
1041
                chargedDevice.withStandbyPowerMeasurements(
1042
                    standbyMeasurementsByChargerID[chargedDevice.id] ?? []
1043
                )
1044
            }
1045

            
1046
            DispatchQueue.main.async { [weak self] in
1047
                guard let self else { return }
1048

            
1049
                self.chargedDevices = summaries
1050
                self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
1051
                for meter in self.meters.values {
1052
                    self.restoreChargeMonitoringStateIfNeeded(for: meter)
1053
                }
Bogdan Timofte authored a month ago
1054

            
1055
                self.chargedDevicesReloadInFlight = false
1056
                if self.chargedDevicesReloadPending {
1057
                    self.reloadChargedDevices()
1058
                }
Bogdan Timofte authored a month ago
1059
            }
Bogdan Timofte authored a month ago
1060
        }
1061
    }
1062

            
1063
    private func meter(for meterMACAddress: String) -> Meter? {
1064
        meters.values.first { meter in
1065
            meter.btSerial.macAddress.description == meterMACAddress
1066
        }
1067
    }
1068

            
Bogdan Timofte authored 2 months ago
1069
    private func refreshMeterMetadata() {
1070
        DispatchQueue.main.async { [weak self] in
1071
            guard let self else { return }
1072
            var didUpdateAnyMeter = false
1073
            for meter in self.meters.values {
1074
                let mac = meter.btSerial.macAddress.description
1075
                let displayName = self.meterName(for: mac) ?? mac
1076
                if meter.name != displayName {
1077
                    meter.updateNameFromStore(displayName)
1078
                    didUpdateAnyMeter = true
1079
                }
1080

            
1081
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
1082
                meter.reloadTemperatureUnitPreference()
1083
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
1084
                    didUpdateAnyMeter = true
1085
                }
1086
            }
1087

            
1088
            if didUpdateAnyMeter {
1089
                self.scheduleObjectWillChange()
1090
            }
1091
        }
1092
    }
Bogdan Timofte authored a month ago
1093

            
Bogdan Timofte authored a month ago
1094
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
1095
        guard let charger = chargedDeviceSummary(id: session.chargerID),
1096
              let statistics = session.statistics else {
1097
            return
1098
        }
1099

            
1100
        let content = UNMutableNotificationContent()
1101
        content.title = "Standby baseline stabilised"
1102
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
1103
        content.sound = .default
1104
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
1105

            
1106
        let request = UNNotificationRequest(
1107
            identifier: "charger-standby-\(session.id.uuidString)",
1108
            content: content,
1109
            trigger: nil
1110
        )
1111
        UNUserNotificationCenter.current().add(request)
1112
        scheduleObjectWillChange()
1113
    }
1114

            
Bogdan Timofte authored a month ago
1115
    private func batteryCheckpointPlausibilityWarning(
1116
        percent: Double,
Bogdan Timofte authored a month ago
1117
        for session: ChargeSessionSummary,
1118
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
1119
    ) -> BatteryCheckpointPlausibilityWarning? {
1120
        guard percent.isFinite, percent >= 0, percent <= 100 else {
1121
            return nil
1122
        }
1123

            
1124
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
1125
            if lhs.timestamp != rhs.timestamp {
1126
                return lhs.timestamp < rhs.timestamp
1127
            }
1128
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1129
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1130
            }
1131
            return lhs.id.uuidString < rhs.id.uuidString
1132
        }
1133

            
1134
        if let lastCheckpoint = sortedCheckpoints.last,
1135
           percent < lastCheckpoint.batteryPercent - 1.5 {
1136
            return BatteryCheckpointPlausibilityWarning(
1137
                title: "Checkpoint Goes Backwards",
1138
                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."
1139
            )
1140
        }
1141

            
Bogdan Timofte authored a month ago
1142
        let effectiveEnergyWh = effectiveEnergyWhOverride
1143
            ?? session.effectiveBatteryEnergyWh
1144
            ?? session.measuredEnergyWh
1145

            
1146
        if let lastCheckpoint = sortedCheckpoints.last,
1147
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
1148
            let estimatedCapacityWh = session.capacityEstimateWh
1149
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1150
                ?? chargedDevice.estimatedBatteryCapacityWh
1151

            
1152
            if let estimatedCapacityWh, estimatedCapacityWh > 0 {
1153
                let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1154
                let expectedPercent = min(
1155
                    100,
1156
                    max(
1157
                        lastCheckpoint.batteryPercent,
1158
                        lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
1159
                    )
1160
                )
1161
                let predictionGap = percent - expectedPercent
1162
                guard abs(predictionGap) >= 4 else {
1163
                    return nil
1164
                }
1165

            
1166
                let direction = predictionGap > 0 ? "above" : "below"
1167
                let gapText = abs(predictionGap).format(decimalDigits: 0)
1168
                let expectedText = expectedPercent.format(decimalDigits: 0)
1169

            
1170
                return BatteryCheckpointPlausibilityWarning(
1171
                    title: "Checkpoint Looks Implausible",
1172
                    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."
1173
                )
1174
            }
1175
        }
1176

            
Bogdan Timofte authored a month ago
1177
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
Bogdan Timofte authored a month ago
1178
              let prediction = chargedDevice.batteryLevelPrediction(
1179
                for: session,
1180
                effectiveEnergyWhOverride: effectiveEnergyWh
1181
              )
Bogdan Timofte authored a month ago
1182
        else {
1183
            return nil
1184
        }
1185

            
1186
        let predictionGap = percent - prediction.predictedPercent
1187
        guard abs(predictionGap) >= 4 else {
1188
            return nil
1189
        }
1190

            
1191
        let direction = predictionGap > 0 ? "above" : "below"
1192
        let gapText = abs(predictionGap).format(decimalDigits: 0)
1193
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
1194

            
1195
        if let lastCheckpoint = sortedCheckpoints.last {
1196
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1197
            return BatteryCheckpointPlausibilityWarning(
1198
                title: "Checkpoint Looks Implausible",
1199
                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."
1200
            )
1201
        }
1202

            
1203
        return BatteryCheckpointPlausibilityWarning(
1204
            title: "Checkpoint Looks Implausible",
1205
            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."
1206
        )
1207
    }
Bogdan Timofte authored a month ago
1208

            
1209
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1210
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
Bogdan Timofte authored a month ago
1211
        guard session.isTrimmed == false else {
1212
            return storedEnergyWh
1213
        }
Bogdan Timofte authored a month ago
1214
        guard session.status.isOpen else {
1215
            return storedEnergyWh
1216
        }
1217

            
1218
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1219
            return storedEnergyWh
1220
        }
1221

            
1222
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1223
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
1224
        }
1225

            
1226
        return storedEnergyWh
1227
    }
1228

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

            
1238
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1239
            return storedChargeAh
1240
        }
1241

            
1242
        if let baselineChargeAh = session.meterChargeBaselineAh {
1243
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
1244
        }
1245

            
1246
        return storedChargeAh
1247
    }
Bogdan Timofte authored 2 months ago
1248
}
Bogdan Timofte authored 2 months ago
1249

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1372
            return nil
1373
        }
1374
    }
1375

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

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

            
1385
        inFlightEventIDs.insert(payload.id)
1386

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

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

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

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

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

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