Newer Older
1387 lines | 50.912kb
Bogdan Timofte authored 2 months ago
1
//
2
//  DataStore.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 03/03/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8

            
9
import SwiftUI
10
import Combine
11
import CoreBluetooth
Bogdan Timofte authored a month ago
12
import CoreData
13
import UserNotifications
Bogdan Timofte authored 2 months ago
14

            
Bogdan Timofte authored a month ago
15
struct BatteryCheckpointPlausibilityWarning: Identifiable, Hashable {
16
    let title: String
17
    let message: String
18

            
19
    var id: String {
20
        "\(title)\n\(message)"
21
    }
22
}
23

            
Bogdan Timofte authored 2 months ago
24
final class AppData : ObservableObject {
Bogdan Timofte authored 2 months ago
25
    struct MeterSummary: Identifiable {
Bogdan Timofte authored 2 months ago
26
        let macAddress: String
27
        let displayName: String
28
        let modelSummary: String
29
        let advertisedName: String?
30
        let lastSeen: Date?
31
        let lastConnected: Date?
32
        let meter: Meter?
33

            
34
        var id: String {
35
            macAddress
36
        }
37
    }
38

            
Bogdan Timofte authored 2 months ago
39
    private var bluetoothManagerNotification: AnyCancellable?
Bogdan Timofte authored 2 months ago
40
    private var meterStoreObserver: AnyCancellable?
41
    private var meterStoreCloudObserver: AnyCancellable?
Bogdan Timofte authored a month ago
42
    private var chargeInsightsStoreObserver: AnyCancellable?
43
    private var chargeInsightsRemoteObserver: AnyCancellable?
Bogdan Timofte authored a month ago
44
    private var chargerStandbyPowerStoreObserver: AnyCancellable?
Bogdan Timofte authored a month ago
45
    private var pendingChargedDevicesReloadWorkItem: DispatchWorkItem?
Bogdan Timofte authored a month ago
46
    private var chargeInsightsReadStore: ChargeInsightsStore?
Bogdan Timofte authored a month ago
47
    private var pendingChargeObservationSnapshots: [String: ChargingMonitorSnapshot] = [:]
48
    private var pendingChargeObservationWorkItems: [String: DispatchWorkItem] = [:]
Bogdan Timofte authored a month ago
49
    private let chargedDevicesReloadQueue = DispatchQueue(
50
        label: "ro.xdev.usb-meter.charged-devices-reload",
51
        qos: .userInitiated
52
    )
Bogdan Timofte authored a month ago
53
    private var chargedDevicesReloadInFlight = false
54
    private var chargedDevicesReloadPending = false
Bogdan Timofte authored a month ago
55
    private let chargeObservationPersistInterval: TimeInterval = 30
56
    private let meterPresencePersistInterval: TimeInterval = 15
Bogdan Timofte authored 2 months ago
57
    private let meterStore = MeterNameStore.shared
Bogdan Timofte authored a month ago
58
    private var chargeInsightsStore: ChargeInsightsStore?
Bogdan Timofte authored a month ago
59
    private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
Bogdan Timofte authored a month ago
60
    private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
Bogdan Timofte authored a month ago
61
    private var meterSummariesCache: (version: Int, summaries: [MeterSummary])?
62
    private var meterSummariesVersion: Int = 0
Bogdan Timofte authored 2 months ago
63

            
Bogdan Timofte authored 2 months ago
64
    init() {
Bogdan Timofte authored 2 months ago
65
        bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
Bogdan Timofte authored 2 months ago
66
            self?.scheduleObjectWillChange()
Bogdan Timofte authored 2 months ago
67
        }
Bogdan Timofte authored 2 months ago
68
        meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange)
69
            .receive(on: DispatchQueue.main)
70
            .sink { [weak self] _ in
Bogdan Timofte authored a month ago
71
                self?.invalidateMeterSummaries()
Bogdan Timofte authored 2 months ago
72
                self?.refreshMeterMetadata()
Bogdan Timofte authored a month ago
73
                self?.scheduleObjectWillChange()
Bogdan Timofte authored 2 months ago
74
            }
75
        meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange)
76
            .receive(on: DispatchQueue.main)
77
            .sink { [weak self] _ in
78
                self?.scheduleObjectWillChange()
79
            }
Bogdan Timofte authored a month ago
80
        chargerStandbyPowerStoreObserver = NotificationCenter.default.publisher(for: .chargerStandbyPowerStoreDidChange)
81
            .receive(on: DispatchQueue.main)
82
            .sink { [weak self] _ in
83
                self?.reloadChargedDevices()
84
            }
Bogdan Timofte authored 2 months ago
85
    }
Bogdan Timofte authored 2 months ago
86

            
Bogdan Timofte authored 2 months ago
87
    let bluetoothManager = BluetoothManager()
Bogdan Timofte authored 2 months ago
88

            
Bogdan Timofte authored 2 months ago
89
    @Published var enableRecordFeature: Bool = true
Bogdan Timofte authored 2 months ago
90

            
Bogdan Timofte authored a month ago
91
    @Published var meters: [UUID:Meter] = [UUID:Meter]() {
92
        didSet {
93
            invalidateMeterSummaries()
94
        }
95
    }
Bogdan Timofte authored a month ago
96
    @Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
Bogdan Timofte authored a month ago
97
    @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:]
Bogdan Timofte authored a month ago
98

            
99
    var deviceSummaries: [ChargedDeviceSummary] {
100
        chargedDevices.filter { !$0.isCharger }
101
    }
102

            
103
    var chargerSummaries: [ChargedDeviceSummary] {
104
        chargedDevices.filter { $0.isCharger }
105
    }
Bogdan Timofte authored 2 months ago
106

            
107
    var cloudAvailability: MeterNameStore.CloudAvailability {
108
        meterStore.currentCloudAvailability
109
    }
110

            
Bogdan Timofte authored a month ago
111
    func activateChargeInsights(context: NSManagedObjectContext) {
112
        guard chargeInsightsStore == nil else {
113
            return
114
        }
115

            
116
        context.automaticallyMergesChangesFromParent = true
Bogdan Timofte authored a month ago
117
        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
Bogdan Timofte authored a month ago
118
        if let coordinator = context.persistentStoreCoordinator {
Bogdan Timofte authored a month ago
119
            let writeContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
120
            writeContext.persistentStoreCoordinator = coordinator
121
            writeContext.automaticallyMergesChangesFromParent = false
122
            writeContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
123
            chargeInsightsStore = ChargeInsightsStore(context: writeContext)
124

            
Bogdan Timofte authored a month ago
125
            let readContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
126
            readContext.persistentStoreCoordinator = coordinator
127
            readContext.automaticallyMergesChangesFromParent = true
128
            readContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
129
            chargeInsightsReadStore = ChargeInsightsStore(context: readContext)
130

            
Bogdan Timofte authored a month ago
131
            chargeInsightsStoreObserver = NotificationCenter.default.publisher(
132
                for: .NSManagedObjectContextDidSave,
133
                object: writeContext
134
            )
135
            .sink { [weak self, weak context] notification in
136
                guard let self, let context else { return }
137
                context.perform {
138
                    context.mergeChanges(fromContextDidSave: notification)
139
                    DispatchQueue.main.async {
140
                        self.scheduleChargedDevicesReload()
141
                    }
142
                }
143
            }
144
        } else {
145
            chargeInsightsStore = ChargeInsightsStore(context: context)
146
            chargeInsightsReadStore = ChargeInsightsStore(context: context)
147

            
148
            chargeInsightsStoreObserver = NotificationCenter.default.publisher(
149
                for: .NSManagedObjectContextDidSave,
150
                object: context
151
            )
152
            .receive(on: DispatchQueue.main)
153
            .sink { [weak self] _ in
154
                self?.scheduleChargedDevicesReload()
155
            }
Bogdan Timofte authored a month ago
156
        }
157

            
158
        chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
159
            for: .NSPersistentStoreRemoteChange,
160
            object: nil
161
        )
162
        .receive(on: DispatchQueue.main)
163
        .sink { [weak self] _ in
Bogdan Timofte authored a month ago
164
            self?.scheduleChargedDevicesReload()
Bogdan Timofte authored a month ago
165
        }
166

            
167
        chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
168
        reloadChargedDevices()
169
    }
170

            
Bogdan Timofte authored 2 months ago
171
    func meterName(for macAddress: String) -> String? {
172
        meterStore.name(for: macAddress)
173
    }
174

            
175
    func setMeterName(_ name: String, for macAddress: String) {
176
        meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
177
    }
178

            
179
    func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
180
        let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
181
        return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
182
    }
183

            
184
    func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
185
        meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
Bogdan Timofte authored 2 months ago
186
    }
Bogdan Timofte authored 2 months ago
187

            
Bogdan Timofte authored 2 months ago
188
    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
189
        meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
Bogdan Timofte authored 2 months ago
190
    }
191

            
192
    func noteMeterSeen(at date: Date, macAddress: String) {
Bogdan Timofte authored a month ago
193
        if let persistedLastSeen = meterStore.lastSeen(for: macAddress),
194
           date.timeIntervalSince(persistedLastSeen) < meterPresencePersistInterval {
195
            return
196
        }
Bogdan Timofte authored 2 months ago
197
        meterStore.noteLastSeen(date, for: macAddress)
198
    }
199

            
200
    func noteMeterConnected(at date: Date, macAddress: String) {
201
        meterStore.noteLastConnected(date, for: macAddress)
202
    }
203

            
204
    func lastSeen(for macAddress: String) -> Date? {
205
        meterStore.lastSeen(for: macAddress)
206
    }
207

            
208
    func lastConnected(for macAddress: String) -> Date? {
209
        meterStore.lastConnected(for: macAddress)
210
    }
211

            
Bogdan Timofte authored a month ago
212
    func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
213
        chargedDevices.first(where: { $0.id == id })
214
    }
215

            
Bogdan Timofte authored a month ago
216
    func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
217
        for chargedDevice in chargedDevices {
218
            if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
219
                return session
220
            }
221
        }
222
        return nil
223
    }
224

            
Bogdan Timofte authored a month ago
225
    func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
226
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
227
        return chargedDevices.filter { chargedDevice in
228
            guard chargedDevice.isCharger == false else {
229
                return false
230
            }
Bogdan Timofte authored a month ago
231
            return chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
232
        }
233
    }
234

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

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

            
Bogdan Timofte authored a month ago
248
        if expireOverlongChargeSessionsIfNeeded() {
249
            reloadChargedDevices()
250
            return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC)
251
        }
252

            
Bogdan Timofte authored a month ago
253
        if let cachedSummary = cachedActiveChargeSessionSummary(for: normalizedMAC) {
Bogdan Timofte authored a month ago
254
            if let persistedSummary = chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC),
255
               persistedSummary.aggregatedSamples.count > cachedSummary.aggregatedSamples.count {
256
                return persistedSummary
257
            }
Bogdan Timofte authored a month ago
258
            return cachedSummary
259
        }
Bogdan Timofte authored a month ago
260
        return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC)
Bogdan Timofte authored a month ago
261
    }
262

            
Bogdan Timofte authored a month ago
263
    func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
264
        activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)]
265
    }
266

            
267
    @discardableResult
268
    func startChargerStandbyMeasurement(for chargerID: UUID, on meter: Meter) -> Bool {
269
        guard chargedDeviceSummary(id: chargerID)?.isCharger == true else {
270
            return false
271
        }
272

            
273
        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
274
        if let existingSession = activeChargerStandbySessions[normalizedMAC] {
275
            return existingSession.chargerID == chargerID
276
        }
277

            
278
        let session = ChargerStandbyPowerMonitorSession(chargerID: chargerID, meter: meter)
279
        session.onChange = { [weak self] in
280
            self?.scheduleObjectWillChange()
281
        }
282
        session.onStabilized = { [weak self, weak session] in
283
            guard let self, let session else { return }
284
            self.notifyChargerStandbyMeasurementReady(for: session)
285
        }
286

            
287
        activeChargerStandbySessions[normalizedMAC] = session
288
        session.start()
289

            
290
        // Starting a standby run on an available meter should also initiate the BLE link.
291
        if meter.operationalState == .peripheralNotConnected {
292
            meter.connect()
293
        }
294

            
295
        scheduleObjectWillChange()
296
        return true
297
    }
298

            
299
    @discardableResult
300
    func finishChargerStandbyMeasurement(for meterMACAddress: String, save: Bool) -> Bool {
301
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
302
        guard let session = activeChargerStandbySessions[normalizedMAC] else {
303
            return false
304
        }
305

            
306
        session.stop()
307

            
308
        guard save else {
309
            activeChargerStandbySessions[normalizedMAC] = nil
310
            scheduleObjectWillChange()
311
            return true
312
        }
313

            
314
        guard let summary = session.makeSummary() else {
315
            scheduleObjectWillChange()
316
            return false
317
        }
318

            
319
        let didSave = chargerStandbyPowerStore.save(summary)
320
        if didSave {
321
            activeChargerStandbySessions[normalizedMAC] = nil
322
            reloadChargedDevices()
323
        } else {
324
            scheduleObjectWillChange()
325
        }
326

            
327
        return didSave
328
    }
329

            
Bogdan Timofte authored a month ago
330
    @discardableResult
331
    func deleteChargerStandbyMeasurement(id: UUID, chargerID: UUID) -> Bool {
332
        let didDelete = chargerStandbyPowerStore.removeMeasurement(id: id, chargerID: chargerID)
333
        if didDelete {
334
            reloadChargedDevices()
335
        } else {
336
            scheduleObjectWillChange()
337
        }
338
        return didDelete
339
    }
340

            
Bogdan Timofte authored a month ago
341
    @discardableResult
Bogdan Timofte authored a month ago
342
    func createDevice(
Bogdan Timofte authored a month ago
343
        name: String,
344
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
345
        templateID: String?,
Bogdan Timofte authored a month ago
346
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
347
        supportsWiredCharging: Bool,
348
        supportsWirelessCharging: Bool,
349
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
350
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
351
        notes: String?
Bogdan Timofte authored a month ago
352
    ) -> Bool {
Bogdan Timofte authored a month ago
353
        let didSave = chargeInsightsStore?.createDevice(
Bogdan Timofte authored a month ago
354
            name: name,
355
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
356
            templateID: templateID,
Bogdan Timofte authored a month ago
357
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
358
            supportsWiredCharging: supportsWiredCharging,
359
            supportsWirelessCharging: supportsWirelessCharging,
360
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
361
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
362
            notes: notes
Bogdan Timofte authored a month ago
363
        ) ?? false
364

            
365
        if didSave {
366
            reloadChargedDevices()
367
        }
368

            
369
        return didSave
370
    }
371

            
372
    @discardableResult
Bogdan Timofte authored a month ago
373
    func createCharger(
374
        name: String,
Bogdan Timofte authored a month ago
375
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
376
        notes: String?
Bogdan Timofte authored a month ago
377
    ) -> Bool {
378
        let didSave = chargeInsightsStore?.createCharger(
379
            name: name,
Bogdan Timofte authored a month ago
380
            chargerType: chargerType,
Bogdan Timofte authored a month ago
381
            notes: notes
Bogdan Timofte authored a month ago
382
        ) ?? false
383

            
384
        if didSave {
385
            reloadChargedDevices()
386
        }
387

            
388
        return didSave
389
    }
390

            
391
    @discardableResult
392
    func updateDevice(
Bogdan Timofte authored a month ago
393
        id: UUID,
394
        name: String,
395
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
396
        templateID: String?,
Bogdan Timofte authored a month ago
397
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
398
        supportsWiredCharging: Bool,
399
        supportsWirelessCharging: Bool,
400
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
401
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
402
        notes: String?
403
    ) -> Bool {
Bogdan Timofte authored a month ago
404
        let didSave = chargeInsightsStore?.updateDevice(
Bogdan Timofte authored a month ago
405
            id: id,
406
            name: name,
407
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
408
            templateID: templateID,
Bogdan Timofte authored a month ago
409
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
410
            supportsWiredCharging: supportsWiredCharging,
411
            supportsWirelessCharging: supportsWirelessCharging,
412
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
413
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
414
            notes: notes
415
        ) ?? false
416

            
417
        if didSave {
418
            reloadChargedDevices()
419
        }
420

            
421
        return didSave
422
    }
423

            
424
    @discardableResult
Bogdan Timofte authored a month ago
425
    func updateCharger(
426
        id: UUID,
427
        name: String,
Bogdan Timofte authored a month ago
428
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
429
        notes: String?
430
    ) -> Bool {
431
        let didSave = chargeInsightsStore?.updateCharger(
432
            id: id,
433
            name: name,
Bogdan Timofte authored a month ago
434
            chargerType: chargerType,
Bogdan Timofte authored a month ago
435
            notes: notes
Bogdan Timofte authored a month ago
436
        ) ?? false
437

            
438
        if didSave {
439
            reloadChargedDevices()
440
        }
441

            
442
        return didSave
443
    }
444

            
Bogdan Timofte authored a month ago
445
    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
446
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
447
            return
448
        }
Bogdan Timofte authored a month ago
449
        guard activeSession.status.isOpen else {
Bogdan Timofte authored a month ago
450
            return
451
        }
452
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
453
    }
454

            
Bogdan Timofte authored a month ago
455
    @discardableResult
Bogdan Timofte authored a month ago
456
    func startChargeSession(
457
        for meter: Meter,
458
        chargedDeviceID: UUID,
459
        chargerID: UUID?,
460
        chargingTransportMode: ChargingTransportMode,
461
        chargingStateMode: ChargingStateMode,
462
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
463
        initialBatteryPercent: Double?,
464
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
465
    ) -> Bool {
Bogdan Timofte authored a month ago
466
        meter.resetMeterCountersForNewSession()
467

            
Bogdan Timofte authored a month ago
468
        guard let snapshot = meter.chargingMonitorSnapshot else {
469
            return false
470
        }
471

            
Bogdan Timofte authored a month ago
472
        let didSave = chargeInsightsStore?.startSession(
473
            for: snapshot,
474
            chargedDeviceID: chargedDeviceID,
475
            chargerID: chargerID,
476
            chargingTransportMode: chargingTransportMode,
477
            chargingStateMode: chargingStateMode,
478
            autoStopEnabled: autoStopEnabled,
Bogdan Timofte authored a month ago
479
            initialBatteryPercent: initialBatteryPercent,
480
            startsFromFlatBattery: startsFromFlatBattery
Bogdan Timofte authored a month ago
481
        ) ?? false
482
        if didSave {
Bogdan Timofte authored a month ago
483
            meter.resetChargeRecordGraph()
Bogdan Timofte authored a month ago
484
            let activeSession = chargeInsightsStore?.activeChargeSessionSummary(
485
                forMeterMACAddress: meter.btSerial.macAddress.description
486
            )
487
            if let activeSession,
Bogdan Timofte authored a month ago
488
               meter.supportsRecordingThreshold,
489
               activeSession.stopThresholdAmps > 0 {
490
                meter.recordingTreshold = activeSession.stopThresholdAmps
491
            }
Bogdan Timofte authored a month ago
492
            if let activeSession {
493
                meter.restoreChargeMonitoringIfNeeded(from: activeSession)
494
            }
495
            reloadChargedDevices()
Bogdan Timofte authored a month ago
496
        }
497
        return didSave
498
    }
499

            
500
    @discardableResult
501
    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
502
        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
Bogdan Timofte authored a month ago
503

            
504
        if let meter {
505
            _ = persistChargeSnapshot(from: meter, observedAt: observedAt)
506
        } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
507
            _ = flushPendingChargeObservation(for: meterMACAddress)
508
        }
509

            
Bogdan Timofte authored a month ago
510
        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
511
        if didSave {
512
            reloadChargedDevices()
513
        }
514
        return didSave
515
    }
516

            
517
    @discardableResult
518
    func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
519
        let snapshot = meter?.chargingMonitorSnapshot
520
        let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
521
        if didSave {
522
            reloadChargedDevices()
523
        }
524
        return didSave
525
    }
526

            
527
    @discardableResult
Bogdan Timofte authored a month ago
528
    func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil, from meter: Meter? = nil) -> Bool {
529
        if let meter {
530
            _ = persistChargeSnapshot(from: meter)
531
        } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
Bogdan Timofte authored a month ago
532
            _ = flushPendingChargeObservation(for: meterMACAddress)
533
        }
534

            
Bogdan Timofte authored a month ago
535
        let didSave = chargeInsightsStore?.stopSession(
536
            id: sessionID,
Bogdan Timofte authored a month ago
537
            finalBatteryPercent: finalBatteryPercent
Bogdan Timofte authored a month ago
538
        ) ?? false
Bogdan Timofte authored a month ago
539
        reloadChargedDevices()
Bogdan Timofte authored a month ago
540
        return didSave
541
    }
542

            
543
    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
544
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
545
            return
546
        }
547

            
Bogdan Timofte authored a month ago
548
        stageChargeObservation(snapshot)
Bogdan Timofte authored a month ago
549
    }
550

            
551
    @discardableResult
Bogdan Timofte authored a month ago
552
    func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
Bogdan Timofte authored a month ago
553
        _ = persistChargeSnapshot(from: meter)
Bogdan Timofte authored a month ago
554

            
555
        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
556
        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
557

            
Bogdan Timofte authored a month ago
558
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
559
            percent: percent,
Bogdan Timofte authored a month ago
560
            for: meter.btSerial.macAddress.description,
Bogdan Timofte authored a month ago
561
            measuredEnergyWh: checkpointEnergyWh
Bogdan Timofte authored a month ago
562
        ) ?? false
563

            
564
        if didSave {
565
            reloadChargedDevices()
566
        }
567

            
568
        return didSave
569
    }
570

            
571
    @discardableResult
Bogdan Timofte authored a month ago
572
    func addBatteryCheckpoint(percent: Double, for sessionID: UUID) -> Bool {
573
        guard canAddBatteryCheckpoint(to: sessionID) else {
574
            return false
575
        }
576

            
Bogdan Timofte authored a month ago
577
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
578
            percent: percent,
579
            for: sessionID
580
        ) ?? false
581

            
582
        if didSave {
583
            reloadChargedDevices()
584
        }
585

            
586
        return didSave
587
    }
588

            
Bogdan Timofte authored a month ago
589
    @discardableResult
590
    func addBatteryCheckpoint(
591
        percent: Double,
592
        for sessionID: UUID,
Bogdan Timofte authored a month ago
593
        measuredEnergyWh: Double?
Bogdan Timofte authored a month ago
594
    ) -> Bool {
Bogdan Timofte authored a month ago
595
        guard canAddBatteryCheckpoint(to: sessionID) else {
596
            return false
597
        }
598

            
Bogdan Timofte authored a month ago
599
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
600
            percent: percent,
601
            for: sessionID,
Bogdan Timofte authored a month ago
602
            measuredEnergyWh: measuredEnergyWh
Bogdan Timofte authored a month ago
603
        ) ?? false
604

            
605
        if didSave {
606
            reloadChargedDevices()
607
        }
608

            
609
        return didSave
610
    }
611

            
Bogdan Timofte authored a month ago
612
    func canAddBatteryCheckpoint(to sessionID: UUID) -> Bool {
613
        guard let session = chargeSessionSummary(id: sessionID),
614
              session.status.isOpen,
615
              let meterMACAddress = session.meterMACAddress else {
616
            return false
617
        }
618

            
619
        return meter(for: meterMACAddress) != nil
620
    }
621

            
622
    func batteryCheckpointCaptureRequirementMessage(for sessionID: UUID) -> String? {
623
        guard let session = chargeSessionSummary(id: sessionID) else {
624
            return "Battery checkpoints are available only while the charge session is still active."
625
        }
626

            
627
        guard session.status.isOpen else {
628
            return "Battery checkpoints are available only while the charge session is still active."
629
        }
630

            
631
        guard let meterMACAddress = session.meterMACAddress,
632
              meter(for: meterMACAddress) != nil else {
633
            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."
634
        }
635

            
636
        return nil
637
    }
638

            
Bogdan Timofte authored a month ago
639
    func batteryCheckpointPlausibilityWarning(
640
        percent: Double,
Bogdan Timofte authored a month ago
641
        for sessionID: UUID,
642
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
643
    ) -> BatteryCheckpointPlausibilityWarning? {
644
        guard let session = chargeSessionSummary(id: sessionID) else {
645
            return nil
646
        }
Bogdan Timofte authored a month ago
647
        return batteryCheckpointPlausibilityWarning(
648
            percent: percent,
649
            for: session,
650
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
651
        )
Bogdan Timofte authored a month ago
652
    }
653

            
654
    @discardableResult
655
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
656
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
657
            id: checkpointID,
658
            from: sessionID
659
        ) ?? false
660

            
661
        if didDelete {
Bogdan Timofte authored a month ago
662
            reloadChargedDevices()
Bogdan Timofte authored a month ago
663
        }
664

            
665
        return didDelete
666
    }
667

            
Bogdan Timofte authored a month ago
668
    @discardableResult
669
    func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
670
        let didSave = chargeInsightsStore?.setSessionTrim(
671
            sessionID: sessionID,
672
            start: start,
673
            end: end
674
        ) ?? false
675
        if didSave {
676
            reloadChargedDevices()
677
        }
678
        return didSave
679
    }
680

            
Bogdan Timofte authored a month ago
681
    @discardableResult
682
    func commitSessionTrim(sessionID: UUID) -> Bool {
683
        let didSave = chargeInsightsStore?.commitSessionTrim(sessionID: sessionID) ?? false
684
        if didSave {
685
            reloadChargedDevices()
686
        }
687
        return didSave
688
    }
689

            
Bogdan Timofte authored a month ago
690
    @discardableResult
691
    func flushChargeInsights() -> Bool {
Bogdan Timofte authored a month ago
692
        let didFlushObservations = flushAllPendingChargeObservations()
Bogdan Timofte authored a month ago
693
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
Bogdan Timofte authored a month ago
694
        if didFlushObservations || didSave {
695
            reloadChargedDevices()
696
        }
697
        return didFlushObservations || didSave
Bogdan Timofte authored a month ago
698
    }
699

            
700
    @discardableResult
701
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
702
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
703
            return false
704
        }
705
        return setTargetBatteryPercent(percent, for: activeSession.id)
706
    }
707

            
708
    @discardableResult
709
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
710
        if percent != nil {
711
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
712
        }
713

            
714
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
715
        if didSave {
716
            reloadChargedDevices()
717
        }
718
        return didSave
719
    }
720

            
721
    @discardableResult
722
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
723
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
724
        if didSave {
725
            reloadChargedDevices()
726
        }
727
        return didSave
728
    }
729

            
730
    @discardableResult
731
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
732
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
733
        if didSave {
734
            reloadChargedDevices()
735
        }
736
        return didSave
737
    }
738

            
739
    @discardableResult
740
    func deleteChargeSession(sessionID: UUID) -> Bool {
741
        let deletedSession = chargedDevices
742
            .flatMap(\.sessions)
743
            .first(where: { $0.id == sessionID })
744

            
745
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
746
        guard didDelete else {
747
            return false
748
        }
749

            
Bogdan Timofte authored a month ago
750
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
751
           let meterMACAddress = deletedSession?.meterMACAddress,
752
           let liveMeter = meter(for: meterMACAddress) {
753
            liveMeter.resetChargeRecord()
754
        }
755

            
756
        reloadChargedDevices()
757
        return true
758
    }
759

            
760
    @discardableResult
761
    func deleteChargedDevice(id: UUID) -> Bool {
762
        let deletedDevice = chargedDeviceSummary(id: id)
763
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
764
        guard didDelete else {
765
            return false
766
        }
767

            
Bogdan Timofte authored a month ago
768
        if deletedDevice?.isCharger == true {
769
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
770
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
771
                session.stop()
772
                activeChargerStandbySessions[meterMACAddress] = nil
773
            }
774
        }
775

            
Bogdan Timofte authored a month ago
776
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
777
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
778
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
779
           let liveMeter = meter(for: meterMACAddress) {
780
            liveMeter.resetChargeRecord()
781
        }
782

            
783
        reloadChargedDevices()
784
        return true
785
    }
786

            
787
    @discardableResult
788
    func createKnownMeter(
789
        macAddress: String,
790
        customName: String?,
791
        modelName: String,
792
        advertisedName: String?
793
    ) -> Bool {
794
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
795
        guard Self.isValidMACAddress(normalizedMAC) else {
796
            return false
797
        }
798

            
799
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
800
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
801
            setMeterName(customName, for: normalizedMAC)
802
        }
803
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
804
        return true
805
    }
806

            
807
    @discardableResult
808
    func deleteMeter(macAddress: String) -> Bool {
809
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
810
        guard Self.isValidMACAddress(normalizedMAC) else {
811
            return false
812
        }
813

            
814
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
815
            meter.disconnect()
816
        }
817
        meters = meters.filter { element in
818
            element.value.btSerial.macAddress.description != normalizedMAC
819
        }
820

            
821
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
822
        if didDelete {
823
            scheduleObjectWillChange()
824
        }
825
        return didDelete
826
    }
827

            
Bogdan Timofte authored 2 months ago
828
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored a month ago
829
        if let meterSummariesCache, meterSummariesCache.version == meterSummariesVersion {
830
            return meterSummariesCache.summaries
831
        }
832

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

            
Bogdan Timofte authored a month ago
837
        let summaries = macAddresses.map { macAddress in
Bogdan Timofte authored 2 months ago
838
            let liveMeter = liveMetersByMAC[macAddress]
839
            let record = recordsByMAC[macAddress]
840

            
Bogdan Timofte authored 2 months ago
841
            return MeterSummary(
Bogdan Timofte authored 2 months ago
842
                macAddress: macAddress,
Bogdan Timofte authored 2 months ago
843
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
Bogdan Timofte authored 2 months ago
844
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
Bogdan Timofte authored 2 months ago
845
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
846
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
847
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
848
                meter: liveMeter
849
            )
850
        }
851
        .sorted { lhs, rhs in
Bogdan Timofte authored 2 months ago
852
            if lhs.meter != nil && rhs.meter == nil {
853
                return true
854
            }
855
            if lhs.meter == nil && rhs.meter != nil {
856
                return false
857
            }
Bogdan Timofte authored 2 months ago
858
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
859
            if byName != .orderedSame {
860
                return byName == .orderedAscending
861
            }
862
            return lhs.macAddress < rhs.macAddress
863
        }
Bogdan Timofte authored a month ago
864

            
865
        meterSummariesCache = (version: meterSummariesVersion, summaries: summaries)
866
        return summaries
Bogdan Timofte authored 2 months ago
867
    }
868

            
Bogdan Timofte authored 2 months ago
869
    private func scheduleObjectWillChange() {
870
        DispatchQueue.main.async { [weak self] in
871
            self?.objectWillChange.send()
872
        }
873
    }
Bogdan Timofte authored 2 months ago
874

            
Bogdan Timofte authored a month ago
875
    private func invalidateMeterSummaries() {
876
        meterSummariesVersion += 1
877
        meterSummariesCache = nil
878
    }
879

            
Bogdan Timofte authored a month ago
880
    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
881
        pendingChargedDevicesReloadWorkItem?.cancel()
882

            
883
        let workItem = DispatchWorkItem { [weak self] in
884
            self?.reloadChargedDevices()
885
        }
886
        pendingChargedDevicesReloadWorkItem = workItem
887
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
888
    }
889

            
Bogdan Timofte authored a month ago
890
    private func stageChargeObservation(_ snapshot: ChargingMonitorSnapshot, scheduleFlush: Bool = true) {
891
        let normalizedMAC = Self.normalizedMACAddress(snapshot.meterMACAddress)
892
        guard !normalizedMAC.isEmpty else {
893
            return
894
        }
895

            
896
        pendingChargeObservationSnapshots[normalizedMAC] = snapshot
897

            
898
        guard scheduleFlush else {
899
            return
900
        }
901

            
902
        guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
903
            return
904
        }
905

            
906
        let workItem = DispatchWorkItem { [weak self] in
907
            guard let self else { return }
908
            self.pendingChargeObservationWorkItems[normalizedMAC] = nil
909
            guard let snapshot = self.pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
910
                return
911
            }
912
            // CoreData write on background — DidSave observer handles the reload
913
            let store = self.chargeInsightsStore
914
            DispatchQueue.global(qos: .utility).async {
915
                store?.observe(snapshot: snapshot)
916
            }
917
        }
918
        pendingChargeObservationWorkItems[normalizedMAC] = workItem
919
        DispatchQueue.main.asyncAfter(deadline: .now() + chargeObservationPersistInterval, execute: workItem)
920
    }
921

            
922
    @discardableResult
923
    private func persistChargeSnapshot(from meter: Meter, observedAt: Date = Date()) -> Bool {
924
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
925
            return false
926
        }
927

            
928
        stageChargeObservation(snapshot, scheduleFlush: false)
929
        return flushPendingChargeObservation(for: snapshot.meterMACAddress)
930
    }
931

            
932
    @discardableResult
933
    private func flushPendingChargeObservation(for meterMACAddress: String) -> Bool {
934
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
935
        guard !normalizedMAC.isEmpty else {
936
            return false
937
        }
938

            
939
        pendingChargeObservationWorkItems[normalizedMAC]?.cancel()
940
        pendingChargeObservationWorkItems[normalizedMAC] = nil
941

            
942
        guard let snapshot = pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
943
            return false
944
        }
945

            
946
        let didSave = chargeInsightsStore?.observe(snapshot: snapshot) ?? false
947
        return didSave
948
    }
949

            
950
    @discardableResult
951
    private func flushAllPendingChargeObservations() -> Bool {
952
        let pendingMeterMACAddresses = Array(pendingChargeObservationSnapshots.keys)
953
        var didSave = false
954

            
955
        for meterMACAddress in pendingMeterMACAddresses {
956
            if flushPendingChargeObservation(for: meterMACAddress) {
957
                didSave = true
958
            }
959
        }
960

            
961
        return didSave
962
    }
963

            
Bogdan Timofte authored a month ago
964
    private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
965
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
966
        guard !normalizedMAC.isEmpty else {
967
            return nil
968
        }
969

            
970
        return chargedDevices
971
            .lazy
972
            .compactMap(\.activeSession)
Bogdan Timofte authored a month ago
973
            .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
974
    }
975

            
Bogdan Timofte authored a month ago
976
    @discardableResult
977
    private func healDuplicateOpenSessions() -> Bool {
978
        chargeInsightsStore?.healDuplicateOpenSessions() ?? false
979
    }
980

            
Bogdan Timofte authored a month ago
981
    @discardableResult
982
    private func expireOverlongChargeSessionsIfNeeded(referenceDate: Date = Date()) -> Bool {
983
        chargeInsightsStore?.completeExpiredOpenSessions(referenceDate: referenceDate) ?? false
984
    }
985

            
Bogdan Timofte authored a month ago
986
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
987
        if Thread.isMainThread == false {
988
            DispatchQueue.main.async { [weak self] in
989
                self?.reloadChargedDevices()
990
            }
991
            return
992
        }
993

            
Bogdan Timofte authored a month ago
994
        pendingChargedDevicesReloadWorkItem?.cancel()
995
        pendingChargedDevicesReloadWorkItem = nil
996

            
Bogdan Timofte authored a month ago
997
        _ = healDuplicateOpenSessions()
Bogdan Timofte authored a month ago
998
        _ = expireOverlongChargeSessionsIfNeeded()
999

            
Bogdan Timofte authored a month ago
1000
        guard chargedDevicesReloadInFlight == false else {
1001
            chargedDevicesReloadPending = true
1002
            return
1003
        }
1004

            
Bogdan Timofte authored a month ago
1005
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
Bogdan Timofte authored a month ago
1006
        let readStore = chargeInsightsReadStore ?? chargeInsightsStore
Bogdan Timofte authored a month ago
1007
        chargedDevicesReloadInFlight = true
1008
        chargedDevicesReloadPending = false
Bogdan Timofte authored a month ago
1009

            
1010
        chargedDevicesReloadQueue.async { [weak self] in
1011
            guard let self else { return }
1012

            
1013
            readStore?.resetContext()
1014
            let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
1015
                chargedDevice.withStandbyPowerMeasurements(
1016
                    standbyMeasurementsByChargerID[chargedDevice.id] ?? []
1017
                )
1018
            }
1019

            
1020
            DispatchQueue.main.async { [weak self] in
1021
                guard let self else { return }
1022

            
1023
                self.chargedDevices = summaries
1024
                self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
1025
                for meter in self.meters.values {
1026
                    self.restoreChargeMonitoringStateIfNeeded(for: meter)
1027
                }
Bogdan Timofte authored a month ago
1028

            
1029
                self.chargedDevicesReloadInFlight = false
1030
                if self.chargedDevicesReloadPending {
1031
                    self.reloadChargedDevices()
1032
                }
Bogdan Timofte authored a month ago
1033
            }
Bogdan Timofte authored a month ago
1034
        }
1035
    }
1036

            
1037
    private func meter(for meterMACAddress: String) -> Meter? {
1038
        meters.values.first { meter in
1039
            meter.btSerial.macAddress.description == meterMACAddress
1040
        }
1041
    }
1042

            
Bogdan Timofte authored 2 months ago
1043
    private func refreshMeterMetadata() {
1044
        DispatchQueue.main.async { [weak self] in
1045
            guard let self else { return }
1046
            var didUpdateAnyMeter = false
1047
            for meter in self.meters.values {
1048
                let mac = meter.btSerial.macAddress.description
1049
                let displayName = self.meterName(for: mac) ?? mac
1050
                if meter.name != displayName {
1051
                    meter.updateNameFromStore(displayName)
1052
                    didUpdateAnyMeter = true
1053
                }
1054

            
1055
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
1056
                meter.reloadTemperatureUnitPreference()
1057
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
1058
                    didUpdateAnyMeter = true
1059
                }
1060
            }
1061

            
1062
            if didUpdateAnyMeter {
1063
                self.scheduleObjectWillChange()
1064
            }
1065
        }
1066
    }
Bogdan Timofte authored a month ago
1067

            
Bogdan Timofte authored a month ago
1068
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
1069
        guard let charger = chargedDeviceSummary(id: session.chargerID),
1070
              let statistics = session.statistics else {
1071
            return
1072
        }
1073

            
1074
        let content = UNMutableNotificationContent()
1075
        content.title = "Standby baseline stabilised"
1076
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
1077
        content.sound = .default
1078
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
1079

            
1080
        let request = UNNotificationRequest(
1081
            identifier: "charger-standby-\(session.id.uuidString)",
1082
            content: content,
1083
            trigger: nil
1084
        )
1085
        UNUserNotificationCenter.current().add(request)
1086
        scheduleObjectWillChange()
1087
    }
1088

            
Bogdan Timofte authored a month ago
1089
    private func batteryCheckpointPlausibilityWarning(
1090
        percent: Double,
Bogdan Timofte authored a month ago
1091
        for session: ChargeSessionSummary,
1092
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
1093
    ) -> BatteryCheckpointPlausibilityWarning? {
1094
        guard percent.isFinite, percent >= 0, percent <= 100 else {
1095
            return nil
1096
        }
1097

            
1098
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
1099
            if lhs.timestamp != rhs.timestamp {
1100
                return lhs.timestamp < rhs.timestamp
1101
            }
1102
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1103
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1104
            }
1105
            return lhs.id.uuidString < rhs.id.uuidString
1106
        }
1107

            
1108
        if let lastCheckpoint = sortedCheckpoints.last,
1109
           percent < lastCheckpoint.batteryPercent - 1.5 {
1110
            return BatteryCheckpointPlausibilityWarning(
1111
                title: "Checkpoint Goes Backwards",
1112
                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."
1113
            )
1114
        }
1115

            
Bogdan Timofte authored a month ago
1116
        let effectiveEnergyWh = effectiveEnergyWhOverride
1117
            ?? session.effectiveBatteryEnergyWh
1118
            ?? session.measuredEnergyWh
1119

            
1120
        if let lastCheckpoint = sortedCheckpoints.last,
1121
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
1122
            let estimatedCapacityWh = session.capacityEstimateWh
1123
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1124
                ?? chargedDevice.estimatedBatteryCapacityWh
1125

            
1126
            if let estimatedCapacityWh, estimatedCapacityWh > 0 {
1127
                let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1128
                let expectedPercent = min(
1129
                    100,
1130
                    max(
1131
                        lastCheckpoint.batteryPercent,
1132
                        lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
1133
                    )
1134
                )
1135
                let predictionGap = percent - expectedPercent
1136
                guard abs(predictionGap) >= 4 else {
1137
                    return nil
1138
                }
1139

            
1140
                let direction = predictionGap > 0 ? "above" : "below"
1141
                let gapText = abs(predictionGap).format(decimalDigits: 0)
1142
                let expectedText = expectedPercent.format(decimalDigits: 0)
1143

            
1144
                return BatteryCheckpointPlausibilityWarning(
1145
                    title: "Checkpoint Looks Implausible",
1146
                    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."
1147
                )
1148
            }
1149
        }
1150

            
Bogdan Timofte authored a month ago
1151
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
Bogdan Timofte authored a month ago
1152
              let prediction = chargedDevice.batteryLevelPrediction(
1153
                for: session,
1154
                effectiveEnergyWhOverride: effectiveEnergyWh
1155
              )
Bogdan Timofte authored a month ago
1156
        else {
1157
            return nil
1158
        }
1159

            
1160
        let predictionGap = percent - prediction.predictedPercent
1161
        guard abs(predictionGap) >= 4 else {
1162
            return nil
1163
        }
1164

            
1165
        let direction = predictionGap > 0 ? "above" : "below"
1166
        let gapText = abs(predictionGap).format(decimalDigits: 0)
1167
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
1168

            
1169
        if let lastCheckpoint = sortedCheckpoints.last {
1170
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1171
            return BatteryCheckpointPlausibilityWarning(
1172
                title: "Checkpoint Looks Implausible",
1173
                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."
1174
            )
1175
        }
1176

            
1177
        return BatteryCheckpointPlausibilityWarning(
1178
            title: "Checkpoint Looks Implausible",
1179
            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."
1180
        )
1181
    }
Bogdan Timofte authored a month ago
1182

            
1183
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1184
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
Bogdan Timofte authored a month ago
1185
        guard session.isTrimmed == false else {
1186
            return storedEnergyWh
1187
        }
Bogdan Timofte authored a month ago
1188
        guard session.status.isOpen else {
1189
            return storedEnergyWh
1190
        }
1191

            
1192
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1193
            return storedEnergyWh
1194
        }
1195

            
1196
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1197
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
1198
        }
1199

            
1200
        return storedEnergyWh
1201
    }
1202

            
Bogdan Timofte authored 2 months ago
1203
}
Bogdan Timofte authored 2 months ago
1204

            
Bogdan Timofte authored a month ago
1205

            
Bogdan Timofte authored 2 months ago
1206
extension AppData.MeterSummary {
1207
    var tint: Color {
1208
        switch modelSummary {
1209
        case "UM25C":
1210
            return .blue
1211
        case "UM34C":
1212
            return .yellow
1213
        case "TC66C":
1214
            return Model.TC66C.color
1215
        default:
1216
            return .secondary
1217
        }
1218
    }
1219
}
Bogdan Timofte authored 2 months ago
1220

            
Bogdan Timofte authored a month ago
1221
extension AppData {
Bogdan Timofte authored 2 months ago
1222
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
1223
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
1224
            return liveName
1225
        }
1226
        if let customName = record?.customName {
1227
            return customName
1228
        }
1229
        if let advertisedName = record?.advertisedName {
1230
            return advertisedName
1231
        }
1232
        if let recordModel = record?.modelName {
1233
            return recordModel
1234
        }
1235
        if let liveModel = liveMeter?.deviceModelSummary {
1236
            return liveModel
1237
        }
1238
        return "Meter"
1239
    }
Bogdan Timofte authored a month ago
1240

            
1241
    static func normalizedMACAddress(_ macAddress: String) -> String {
1242
        macAddress
1243
            .trimmingCharacters(in: .whitespacesAndNewlines)
1244
            .uppercased()
1245
    }
1246

            
1247
    static func isValidMACAddress(_ macAddress: String) -> Bool {
1248
        macAddress.range(
1249
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
1250
            options: .regularExpression
1251
        ) != nil
1252
    }
1253
}
1254

            
1255
private final class ChargeNotificationCoordinator {
1256
    private struct Payload {
1257
        let id: String
1258
        let title: String
1259
        let body: String
1260
        let threadIdentifier: String
1261
    }
1262

            
1263
    private let notificationCenter = UNUserNotificationCenter.current()
1264
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
1265
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
1266
    private var inFlightEventIDs: Set<String> = []
1267

            
1268
    func ensureAuthorizationIfNeeded() {
1269
        notificationCenter.getNotificationSettings { [weak self] settings in
1270
            guard settings.authorizationStatus == .notDetermined else {
1271
                return
1272
            }
1273

            
1274
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
1275
                if let error {
1276
                    track("Notification authorization request failed: \(error.localizedDescription)")
1277
                }
1278
            }
1279
        }
1280
    }
1281

            
1282
    func process(chargedDevices: [ChargedDeviceSummary]) {
1283
        let now = Date()
1284
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
1285
            payloads(for: chargedDevice, now: now)
1286
        }
1287

            
1288
        for payload in pendingPayloads {
1289
            scheduleIfNeeded(payload)
1290
        }
1291
    }
1292

            
1293
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1294
        chargedDevice.sessions.compactMap { session in
1295
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1296
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1297
               let targetBatteryPercent = session.targetBatteryPercent {
1298
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1299
                    ?? session.endBatteryPercent
1300
                    ?? targetBatteryPercent
1301

            
1302
                return Payload(
1303
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1304
                    title: "Battery target reached",
1305
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1306
                    threadIdentifier: session.id.uuidString
1307
                )
1308
            }
1309

            
1310
            if session.requiresCompletionConfirmation,
1311
               let requestedAt = session.completionConfirmationRequestedAt,
1312
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
1313
                let estimatedPercent = session.completionContradictionPercent
1314
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1315
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
1316
                let detail = estimatedPercent.map {
1317
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
1318
                } ?? ""
1319

            
1320
                return Payload(
1321
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1322
                    title: "Confirm charge completion",
1323
                    body: bodyPrefix + detail,
1324
                    threadIdentifier: session.id.uuidString
1325
                )
1326
            }
1327

            
1328
            return nil
1329
        }
1330
    }
1331

            
1332
    private func scheduleIfNeeded(_ payload: Payload) {
1333
        guard deliveredEventIDs().contains(payload.id) == false else {
1334
            return
1335
        }
1336

            
1337
        guard inFlightEventIDs.contains(payload.id) == false else {
1338
            return
1339
        }
1340

            
1341
        inFlightEventIDs.insert(payload.id)
1342

            
1343
        notificationCenter.getNotificationSettings { [weak self] settings in
1344
            guard let self else { return }
1345
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1346
                DispatchQueue.main.async {
1347
                    self.inFlightEventIDs.remove(payload.id)
1348
                }
1349
                return
1350
            }
1351

            
1352
            let content = UNMutableNotificationContent()
1353
            content.title = payload.title
1354
            content.body = payload.body
1355
            content.sound = .default
1356
            content.threadIdentifier = payload.threadIdentifier
1357

            
1358
            let request = UNNotificationRequest(
1359
                identifier: payload.id,
1360
                content: content,
1361
                trigger: nil
1362
            )
1363

            
1364
            self.notificationCenter.add(request) { error in
1365
                DispatchQueue.main.async {
1366
                    self.inFlightEventIDs.remove(payload.id)
1367
                    if let error {
1368
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1369
                        return
1370
                    }
1371
                    self.storeDeliveredEventID(payload.id)
1372
                }
1373
            }
1374
        }
1375
    }
1376

            
1377
    private func deliveredEventIDs() -> Set<String> {
1378
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1379
        return Set(values)
1380
    }
1381

            
1382
    private func storeDeliveredEventID(_ id: String) {
1383
        var values = deliveredEventIDs()
1384
        values.insert(id)
1385
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1386
    }
Bogdan Timofte authored 2 months ago
1387
}