Newer Older
1447 lines | 53.419kb
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) -> Bool {
Bogdan Timofte authored a month ago
584
        if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
585
            _ = flushPendingChargeObservation(for: meterMACAddress)
586
        }
587

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

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

            
Bogdan Timofte authored a month ago
601
        stageChargeObservation(snapshot)
Bogdan Timofte authored a month ago
602
    }
603

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

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

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

            
619
        if didSave {
620
            reloadChargedDevices()
621
        }
622

            
623
        return didSave
624
    }
625

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

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

            
637
        if didSave {
638
            reloadChargedDevices()
639
        }
640

            
641
        return didSave
642
    }
643

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

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

            
662
        if didSave {
663
            reloadChargedDevices()
664
        }
665

            
666
        return didSave
667
    }
668

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

            
676
        return meter(for: meterMACAddress) != nil
677
    }
678

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

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

            
688
        guard let meterMACAddress = session.meterMACAddress,
689
              meter(for: meterMACAddress) != nil else {
690
            return "Add battery checkpoints only on the device that is actively monitoring this charging session. Devices following the session through iCloud may not have data that is fresh or precise enough."
691
        }
692

            
693
        return nil
694
    }
695

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

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

            
718
        if didDelete {
Bogdan Timofte authored a month ago
719
            reloadChargedDevices()
Bogdan Timofte authored a month ago
720
        }
721

            
722
        return didDelete
723
    }
724

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

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

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

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

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

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

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

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

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

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

            
804
        reloadChargedDevices()
805
        return true
806
    }
807

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

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

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

            
831
        reloadChargedDevices()
832
        return true
833
    }
834

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
944
        pendingChargeObservationSnapshots[normalizedMAC] = snapshot
945

            
946
        guard scheduleFlush else {
947
            return
948
        }
949

            
950
        guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
951
            return
952
        }
953

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

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

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

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

            
987
        pendingChargeObservationWorkItems[normalizedMAC]?.cancel()
988
        pendingChargeObservationWorkItems[normalizedMAC] = nil
989

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

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

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

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

            
1009
        return didSave
1010
    }
1011

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1242
        return storedEnergyWh
1243
    }
1244

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1388
            return nil
1389
        }
1390
    }
1391

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

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

            
1401
        inFlightEventIDs.insert(payload.id)
1402

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

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

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

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

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

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