Newer Older
1443 lines | 53.149kb
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
            return cachedSummary
288
        }
Bogdan Timofte authored a month ago
289
        return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC)
Bogdan Timofte authored a month ago
290
    }
291

            
Bogdan Timofte authored a month ago
292
    func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
293
        activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)]
294
    }
295

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

            
302
        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
303
        if let existingSession = activeChargerStandbySessions[normalizedMAC] {
304
            return existingSession.chargerID == chargerID
305
        }
306

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

            
316
        activeChargerStandbySessions[normalizedMAC] = session
317
        session.start()
318

            
319
        // Starting a standby run on an available meter should also initiate the BLE link.
320
        if meter.operationalState == .peripheralNotConnected {
321
            meter.connect()
322
        }
323

            
324
        scheduleObjectWillChange()
325
        return true
326
    }
327

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

            
335
        session.stop()
336

            
337
        guard save else {
338
            activeChargerStandbySessions[normalizedMAC] = nil
339
            scheduleObjectWillChange()
340
            return true
341
        }
342

            
343
        guard let summary = session.makeSummary() else {
344
            scheduleObjectWillChange()
345
            return false
346
        }
347

            
348
        let didSave = chargerStandbyPowerStore.save(summary)
349
        if didSave {
350
            activeChargerStandbySessions[normalizedMAC] = nil
351
            reloadChargedDevices()
352
        } else {
353
            scheduleObjectWillChange()
354
        }
355

            
356
        return didSave
357
    }
358

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

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

            
396
        if didSave {
397
            reloadChargedDevices()
398
        }
399

            
400
        return didSave
401
    }
402

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

            
417
        if didSave {
418
            reloadChargedDevices()
419
        }
420

            
421
        return didSave
422
    }
423

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

            
450
        if didSave {
451
            reloadChargedDevices()
452
        }
453

            
454
        return didSave
455
    }
456

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

            
471
        if didSave {
472
            reloadChargedDevices()
473
        }
474

            
475
        return didSave
476
    }
477

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

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

            
Bogdan Timofte authored a month ago
496
    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
497
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
498
            return
499
        }
500
        guard activeSession.status == .active else {
501
            return
502
        }
503
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
504
    }
505

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

            
Bogdan Timofte authored a month ago
519
        guard let snapshot = meter.chargingMonitorSnapshot else {
520
            return false
521
        }
522

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

            
551
    @discardableResult
552
    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
553
        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
Bogdan Timofte authored a month ago
554

            
555
        if let meter {
556
            _ = persistChargeSnapshot(from: meter, observedAt: observedAt)
557
        } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
558
            _ = flushPendingChargeObservation(for: meterMACAddress)
559
        }
560

            
Bogdan Timofte authored a month ago
561
        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
562
        if didSave {
563
            reloadChargedDevices()
564
        }
565
        return didSave
566
    }
567

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

            
578
    @discardableResult
Bogdan Timofte authored a month ago
579
    func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil) -> Bool {
Bogdan Timofte authored a month ago
580
        if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
581
            _ = flushPendingChargeObservation(for: meterMACAddress)
582
        }
583

            
Bogdan Timofte authored a month ago
584
        let didSave = chargeInsightsStore?.stopSession(
585
            id: sessionID,
Bogdan Timofte authored a month ago
586
            finalBatteryPercent: finalBatteryPercent
Bogdan Timofte authored a month ago
587
        ) ?? false
Bogdan Timofte authored a month ago
588
        reloadChargedDevices()
Bogdan Timofte authored a month ago
589
        return didSave
590
    }
591

            
592
    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
593
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
594
            return
595
        }
596

            
Bogdan Timofte authored a month ago
597
        stageChargeObservation(snapshot)
Bogdan Timofte authored a month ago
598
    }
599

            
600
    @discardableResult
Bogdan Timofte authored a month ago
601
    func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
Bogdan Timofte authored a month ago
602
        _ = persistChargeSnapshot(from: meter)
Bogdan Timofte authored a month ago
603

            
604
        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
605
        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
606
        let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
607

            
Bogdan Timofte authored a month ago
608
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
609
            percent: percent,
Bogdan Timofte authored a month ago
610
            for: meter.btSerial.macAddress.description,
611
            measuredEnergyWh: checkpointEnergyWh,
612
            measuredChargeAh: checkpointChargeAh
Bogdan Timofte authored a month ago
613
        ) ?? false
614

            
615
        if didSave {
616
            reloadChargedDevices()
617
        }
618

            
619
        return didSave
620
    }
621

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

            
Bogdan Timofte authored a month ago
628
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
629
            percent: percent,
630
            for: sessionID
631
        ) ?? false
632

            
633
        if didSave {
634
            reloadChargedDevices()
635
        }
636

            
637
        return didSave
638
    }
639

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

            
Bogdan Timofte authored a month ago
651
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
652
            percent: percent,
653
            for: sessionID,
654
            measuredEnergyWh: measuredEnergyWh,
655
            measuredChargeAh: measuredChargeAh
656
        ) ?? false
657

            
658
        if didSave {
659
            reloadChargedDevices()
660
        }
661

            
662
        return didSave
663
    }
664

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

            
672
        return meter(for: meterMACAddress) != nil
673
    }
674

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

            
680
        guard session.status.isOpen else {
681
            return "Battery checkpoints are available only while the charge session is still active."
682
        }
683

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

            
689
        return nil
690
    }
691

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

            
707
    @discardableResult
708
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
709
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
710
            id: checkpointID,
711
            from: sessionID
712
        ) ?? false
713

            
714
        if didDelete {
Bogdan Timofte authored a month ago
715
            reloadChargedDevices()
Bogdan Timofte authored a month ago
716
        }
717

            
718
        return didDelete
719
    }
720

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

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

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

            
752
    @discardableResult
753
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
754
        if percent != nil {
755
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
756
        }
757

            
758
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
759
        if didSave {
760
            reloadChargedDevices()
761
        }
762
        return didSave
763
    }
764

            
765
    @discardableResult
766
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
767
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
768
        if didSave {
769
            reloadChargedDevices()
770
        }
771
        return didSave
772
    }
773

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

            
783
    @discardableResult
784
    func deleteChargeSession(sessionID: UUID) -> Bool {
785
        let deletedSession = chargedDevices
786
            .flatMap(\.sessions)
787
            .first(where: { $0.id == sessionID })
788

            
789
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
790
        guard didDelete else {
791
            return false
792
        }
793

            
Bogdan Timofte authored a month ago
794
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
795
           let meterMACAddress = deletedSession?.meterMACAddress,
796
           let liveMeter = meter(for: meterMACAddress) {
797
            liveMeter.resetChargeRecord()
798
        }
799

            
800
        reloadChargedDevices()
801
        return true
802
    }
803

            
804
    @discardableResult
805
    func deleteChargedDevice(id: UUID) -> Bool {
806
        let deletedDevice = chargedDeviceSummary(id: id)
807
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
808
        guard didDelete else {
809
            return false
810
        }
811

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

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

            
827
        reloadChargedDevices()
828
        return true
829
    }
830

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

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

            
851
    @discardableResult
852
    func deleteMeter(macAddress: String) -> Bool {
853
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
854
        guard Self.isValidMACAddress(normalizedMAC) else {
855
            return false
856
        }
857

            
858
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
859
            meter.disconnect()
860
        }
861
        meters = meters.filter { element in
862
            element.value.btSerial.macAddress.description != normalizedMAC
863
        }
864

            
865
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
866
        if didDelete {
867
            scheduleObjectWillChange()
868
        }
869
        return didDelete
870
    }
871

            
Bogdan Timofte authored 2 months ago
872
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored a month ago
873
        if let meterSummariesCache, meterSummariesCache.version == meterSummariesVersion {
874
            return meterSummariesCache.summaries
875
        }
876

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

            
Bogdan Timofte authored a month ago
881
        let summaries = macAddresses.map { macAddress in
Bogdan Timofte authored 2 months ago
882
            let liveMeter = liveMetersByMAC[macAddress]
883
            let record = recordsByMAC[macAddress]
884

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

            
909
        meterSummariesCache = (version: meterSummariesVersion, summaries: summaries)
910
        return summaries
Bogdan Timofte authored 2 months ago
911
    }
912

            
Bogdan Timofte authored 2 months ago
913
    private func scheduleObjectWillChange() {
914
        DispatchQueue.main.async { [weak self] in
915
            self?.objectWillChange.send()
916
        }
917
    }
Bogdan Timofte authored 2 months ago
918

            
Bogdan Timofte authored a month ago
919
    private func invalidateMeterSummaries() {
920
        meterSummariesVersion += 1
921
        meterSummariesCache = nil
922
    }
923

            
Bogdan Timofte authored a month ago
924
    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
925
        pendingChargedDevicesReloadWorkItem?.cancel()
926

            
927
        let workItem = DispatchWorkItem { [weak self] in
928
            self?.reloadChargedDevices()
929
        }
930
        pendingChargedDevicesReloadWorkItem = workItem
931
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
932
    }
933

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

            
940
        pendingChargeObservationSnapshots[normalizedMAC] = snapshot
941

            
942
        guard scheduleFlush else {
943
            return
944
        }
945

            
946
        guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
947
            return
948
        }
949

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

            
966
    @discardableResult
967
    private func persistChargeSnapshot(from meter: Meter, observedAt: Date = Date()) -> Bool {
968
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
969
            return false
970
        }
971

            
972
        stageChargeObservation(snapshot, scheduleFlush: false)
973
        return flushPendingChargeObservation(for: snapshot.meterMACAddress)
974
    }
975

            
976
    @discardableResult
977
    private func flushPendingChargeObservation(for meterMACAddress: String) -> Bool {
978
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
979
        guard !normalizedMAC.isEmpty else {
980
            return false
981
        }
982

            
983
        pendingChargeObservationWorkItems[normalizedMAC]?.cancel()
984
        pendingChargeObservationWorkItems[normalizedMAC] = nil
985

            
986
        guard let snapshot = pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
987
            return false
988
        }
989

            
990
        let didSave = chargeInsightsStore?.observe(snapshot: snapshot) ?? false
991
        return didSave
992
    }
993

            
994
    @discardableResult
995
    private func flushAllPendingChargeObservations() -> Bool {
996
        let pendingMeterMACAddresses = Array(pendingChargeObservationSnapshots.keys)
997
        var didSave = false
998

            
999
        for meterMACAddress in pendingMeterMACAddresses {
1000
            if flushPendingChargeObservation(for: meterMACAddress) {
1001
                didSave = true
1002
            }
1003
        }
1004

            
1005
        return didSave
1006
    }
1007

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

            
1014
        return chargedDevices
1015
            .lazy
1016
            .compactMap(\.activeSession)
Bogdan Timofte authored a month ago
1017
            .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
1018
    }
1019

            
Bogdan Timofte authored a month ago
1020
    @discardableResult
1021
    private func expireOverlongChargeSessionsIfNeeded(referenceDate: Date = Date()) -> Bool {
1022
        chargeInsightsStore?.completeExpiredOpenSessions(referenceDate: referenceDate) ?? false
1023
    }
1024

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

            
Bogdan Timofte authored a month ago
1033
        pendingChargedDevicesReloadWorkItem?.cancel()
1034
        pendingChargedDevicesReloadWorkItem = nil
1035

            
Bogdan Timofte authored a month ago
1036
        _ = expireOverlongChargeSessionsIfNeeded()
1037

            
Bogdan Timofte authored a month ago
1038
        guard chargedDevicesReloadInFlight == false else {
1039
            chargedDevicesReloadPending = true
1040
            return
1041
        }
1042

            
Bogdan Timofte authored a month ago
1043
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
Bogdan Timofte authored a month ago
1044
        let readStore = chargeInsightsReadStore ?? chargeInsightsStore
Bogdan Timofte authored a month ago
1045
        chargedDevicesReloadInFlight = true
1046
        chargedDevicesReloadPending = false
Bogdan Timofte authored a month ago
1047

            
1048
        chargedDevicesReloadQueue.async { [weak self] in
1049
            guard let self else { return }
1050

            
1051
            readStore?.resetContext()
1052
            let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
1053
                chargedDevice.withStandbyPowerMeasurements(
1054
                    standbyMeasurementsByChargerID[chargedDevice.id] ?? []
1055
                )
1056
            }
1057

            
1058
            DispatchQueue.main.async { [weak self] in
1059
                guard let self else { return }
1060

            
1061
                self.chargedDevices = summaries
1062
                self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
1063
                for meter in self.meters.values {
1064
                    self.restoreChargeMonitoringStateIfNeeded(for: meter)
1065
                }
Bogdan Timofte authored a month ago
1066

            
1067
                self.chargedDevicesReloadInFlight = false
1068
                if self.chargedDevicesReloadPending {
1069
                    self.reloadChargedDevices()
1070
                }
Bogdan Timofte authored a month ago
1071
            }
Bogdan Timofte authored a month ago
1072
        }
1073
    }
1074

            
1075
    private func meter(for meterMACAddress: String) -> Meter? {
1076
        meters.values.first { meter in
1077
            meter.btSerial.macAddress.description == meterMACAddress
1078
        }
1079
    }
1080

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

            
1093
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
1094
                meter.reloadTemperatureUnitPreference()
1095
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
1096
                    didUpdateAnyMeter = true
1097
                }
1098
            }
1099

            
1100
            if didUpdateAnyMeter {
1101
                self.scheduleObjectWillChange()
1102
            }
1103
        }
1104
    }
Bogdan Timofte authored a month ago
1105

            
Bogdan Timofte authored a month ago
1106
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
1107
        guard let charger = chargedDeviceSummary(id: session.chargerID),
1108
              let statistics = session.statistics else {
1109
            return
1110
        }
1111

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

            
1118
        let request = UNNotificationRequest(
1119
            identifier: "charger-standby-\(session.id.uuidString)",
1120
            content: content,
1121
            trigger: nil
1122
        )
1123
        UNUserNotificationCenter.current().add(request)
1124
        scheduleObjectWillChange()
1125
    }
1126

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

            
1136
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
1137
            if lhs.timestamp != rhs.timestamp {
1138
                return lhs.timestamp < rhs.timestamp
1139
            }
1140
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1141
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1142
            }
1143
            return lhs.id.uuidString < rhs.id.uuidString
1144
        }
1145

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

            
Bogdan Timofte authored a month ago
1154
        let effectiveEnergyWh = effectiveEnergyWhOverride
1155
            ?? session.effectiveBatteryEnergyWh
1156
            ?? session.measuredEnergyWh
1157

            
1158
        if let lastCheckpoint = sortedCheckpoints.last,
1159
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
1160
            let estimatedCapacityWh = session.capacityEstimateWh
1161
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1162
                ?? chargedDevice.estimatedBatteryCapacityWh
1163

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

            
1178
                let direction = predictionGap > 0 ? "above" : "below"
1179
                let gapText = abs(predictionGap).format(decimalDigits: 0)
1180
                let expectedText = expectedPercent.format(decimalDigits: 0)
1181

            
1182
                return BatteryCheckpointPlausibilityWarning(
1183
                    title: "Checkpoint Looks Implausible",
1184
                    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."
1185
                )
1186
            }
1187
        }
1188

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

            
1198
        let predictionGap = percent - prediction.predictedPercent
1199
        guard abs(predictionGap) >= 4 else {
1200
            return nil
1201
        }
1202

            
1203
        let direction = predictionGap > 0 ? "above" : "below"
1204
        let gapText = abs(predictionGap).format(decimalDigits: 0)
1205
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
1206

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

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

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

            
1230
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1231
            return storedEnergyWh
1232
        }
1233

            
1234
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1235
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
1236
        }
1237

            
1238
        return storedEnergyWh
1239
    }
1240

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

            
1250
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1251
            return storedChargeAh
1252
        }
1253

            
1254
        if let baselineChargeAh = session.meterChargeBaselineAh {
1255
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
1256
        }
1257

            
1258
        return storedChargeAh
1259
    }
Bogdan Timofte authored 2 months ago
1260
}
Bogdan Timofte authored 2 months ago
1261

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

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

            
1297
    static func normalizedMACAddress(_ macAddress: String) -> String {
1298
        macAddress
1299
            .trimmingCharacters(in: .whitespacesAndNewlines)
1300
            .uppercased()
1301
    }
1302

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

            
1311
private final class ChargeNotificationCoordinator {
1312
    private struct Payload {
1313
        let id: String
1314
        let title: String
1315
        let body: String
1316
        let threadIdentifier: String
1317
    }
1318

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

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

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

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

            
1344
        for payload in pendingPayloads {
1345
            scheduleIfNeeded(payload)
1346
        }
1347
    }
1348

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

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

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

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

            
1384
            return nil
1385
        }
1386
    }
1387

            
1388
    private func scheduleIfNeeded(_ payload: Payload) {
1389
        guard deliveredEventIDs().contains(payload.id) == false else {
1390
            return
1391
        }
1392

            
1393
        guard inFlightEventIDs.contains(payload.id) == false else {
1394
            return
1395
        }
1396

            
1397
        inFlightEventIDs.insert(payload.id)
1398

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

            
1408
            let content = UNMutableNotificationContent()
1409
            content.title = payload.title
1410
            content.body = payload.body
1411
            content.sound = .default
1412
            content.threadIdentifier = payload.threadIdentifier
1413

            
1414
            let request = UNNotificationRequest(
1415
                identifier: payload.id,
1416
                content: content,
1417
                trigger: nil
1418
            )
1419

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

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

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