Newer Older
1449 lines | 53.526kb
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

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

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

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

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

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

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

            
320
        activeChargerStandbySessions[normalizedMAC] = session
321
        session.start()
322

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

            
328
        scheduleObjectWillChange()
329
        return true
330
    }
331

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

            
339
        session.stop()
340

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

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

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

            
360
        return didSave
361
    }
362

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

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

            
400
        if didSave {
401
            reloadChargedDevices()
402
        }
403

            
404
        return didSave
405
    }
406

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

            
421
        if didSave {
422
            reloadChargedDevices()
423
        }
424

            
425
        return didSave
426
    }
427

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

            
454
        if didSave {
455
            reloadChargedDevices()
456
        }
457

            
458
        return didSave
459
    }
460

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

            
475
        if didSave {
476
            reloadChargedDevices()
477
        }
478

            
479
        return didSave
480
    }
481

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored a month ago
603
        stageChargeObservation(snapshot)
Bogdan Timofte authored a month ago
604
    }
605

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

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

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

            
621
        if didSave {
622
            reloadChargedDevices()
623
        }
624

            
625
        return didSave
626
    }
627

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

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

            
639
        if didSave {
640
            reloadChargedDevices()
641
        }
642

            
643
        return didSave
644
    }
645

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

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

            
664
        if didSave {
665
            reloadChargedDevices()
666
        }
667

            
668
        return didSave
669
    }
670

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

            
678
        return meter(for: meterMACAddress) != nil
679
    }
680

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

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

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

            
695
        return nil
696
    }
697

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

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

            
720
        if didDelete {
Bogdan Timofte authored a month ago
721
            reloadChargedDevices()
Bogdan Timofte authored a month ago
722
        }
723

            
724
        return didDelete
725
    }
726

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

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

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

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

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

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

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

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

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

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

            
806
        reloadChargedDevices()
807
        return true
808
    }
809

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

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

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

            
833
        reloadChargedDevices()
834
        return true
835
    }
836

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
946
        pendingChargeObservationSnapshots[normalizedMAC] = snapshot
947

            
948
        guard scheduleFlush else {
949
            return
950
        }
951

            
952
        guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
953
            return
954
        }
955

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

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

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

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

            
989
        pendingChargeObservationWorkItems[normalizedMAC]?.cancel()
990
        pendingChargeObservationWorkItems[normalizedMAC] = nil
991

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

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

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

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

            
1011
        return didSave
1012
    }
1013

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1152
        if let lastCheckpoint = sortedCheckpoints.last,
1153
           percent < lastCheckpoint.batteryPercent - 1.5 {
1154
            return BatteryCheckpointPlausibilityWarning(
1155
                title: "Checkpoint Goes Backwards",
1156
                message: "The latest checkpoint is \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.timestamp.format()). A new value of \(percent.format(decimalDigits: 0))% is unexpectedly lower while the session is still charging."
1157
            )
1158
        }
1159

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

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

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

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

            
1188
                return BatteryCheckpointPlausibilityWarning(
1189
                    title: "Checkpoint Looks Implausible",
1190
                    message: "The last checkpoint stored \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh. The current counted energy is \(effectiveEnergyWh.format(decimalDigits: 2)) Wh, which supports about \(expectedText)% based on \(estimatedCapacityWh.format(decimalDigits: 2)) Wh capacity. The entered value is about \(gapText) percentage points \(direction) that."
1191
                )
1192
            }
1193
        }
1194

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

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

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

            
1213
        if let lastCheckpoint = sortedCheckpoints.last {
1214
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1215
            return BatteryCheckpointPlausibilityWarning(
1216
                title: "Checkpoint Looks Implausible",
1217
                message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Since the last checkpoint at \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))%, only \(energyDeltaWh.format(decimalDigits: 2)) Wh were added."
1218
            )
1219
        }
1220

            
1221
        return BatteryCheckpointPlausibilityWarning(
1222
            title: "Checkpoint Looks Implausible",
1223
            message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Save it only if the device gauge really jumped that much."
1224
        )
1225
    }
Bogdan Timofte authored a month ago
1226

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

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

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

            
1244
        return storedEnergyWh
1245
    }
1246

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

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

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

            
1264
        return storedChargeAh
1265
    }
Bogdan Timofte authored 2 months ago
1266
}
Bogdan Timofte authored 2 months ago
1267

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

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

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

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

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

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

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

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

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

            
1350
        for payload in pendingPayloads {
1351
            scheduleIfNeeded(payload)
1352
        }
1353
    }
1354

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

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

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

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

            
1390
            return nil
1391
        }
1392
    }
1393

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

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

            
1403
        inFlightEventIDs.insert(payload.id)
1404

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

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

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

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

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

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