Newer Older
1493 lines | 54.694kb
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 powerbanks: [PowerbankSummary] = []
Bogdan Timofte authored a month ago
98
    @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:]
Bogdan Timofte authored a month ago
99

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

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

            
Bogdan Timofte authored a month ago
108
    var powerbankSummaries: [PowerbankSummary] {
109
        powerbanks
110
    }
111

            
Bogdan Timofte authored 2 months ago
112
    var cloudAvailability: MeterNameStore.CloudAvailability {
113
        meterStore.currentCloudAvailability
114
    }
115

            
Bogdan Timofte authored a month ago
116
    func activateChargeInsights(context: NSManagedObjectContext) {
117
        guard chargeInsightsStore == nil else {
118
            return
119
        }
120

            
121
        context.automaticallyMergesChangesFromParent = true
Bogdan Timofte authored a month ago
122
        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
Bogdan Timofte authored a month ago
123
        if let coordinator = context.persistentStoreCoordinator {
Bogdan Timofte authored a month ago
124
            let writeContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
125
            writeContext.persistentStoreCoordinator = coordinator
126
            writeContext.automaticallyMergesChangesFromParent = false
127
            writeContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
128
            chargeInsightsStore = ChargeInsightsStore(context: writeContext)
129

            
Bogdan Timofte authored a month ago
130
            let readContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
131
            readContext.persistentStoreCoordinator = coordinator
132
            readContext.automaticallyMergesChangesFromParent = true
133
            readContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
134
            chargeInsightsReadStore = ChargeInsightsStore(context: readContext)
135

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

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

            
163
        chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
164
            for: .NSPersistentStoreRemoteChange,
165
            object: nil
166
        )
167
        .receive(on: DispatchQueue.main)
168
        .sink { [weak self] _ in
Bogdan Timofte authored a month ago
169
            self?.scheduleChargedDevicesReload()
Bogdan Timofte authored a month ago
170
        }
171

            
172
        chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
Bogdan Timofte authored a month ago
173
        seedDeviceProfilesCatalogIfNeeded()
174
        migrateDeviceProfilesIfNeeded()
Bogdan Timofte authored a month ago
175
        reloadChargedDevices()
176
    }
177

            
Bogdan Timofte authored a month ago
178
    private static let cloudProfileSeedVersionKey = "cloudProfileSeedVersion"
179
    private static let currentCloudProfileSeedVersion: Int = 1
180
    private static let cloudDeviceProfileMigrationVersionKey = "cloudDeviceProfileMigrationVersion"
181
    private static let currentCloudDeviceProfileMigrationVersion: Int = 1
182

            
183
    private func seedDeviceProfilesCatalogIfNeeded() {
184
        let defaults = UserDefaults.standard
185
        let installed = defaults.integer(forKey: AppData.cloudProfileSeedVersionKey)
186
        guard installed < AppData.currentCloudProfileSeedVersion else { return }
187

            
188
        let catalog = DeviceProfileCatalog.shared.profiles
189
        guard catalog.isEmpty == false else { return }
190

            
191
        if chargeInsightsStore?.seedDeviceProfilesCatalog(catalog) == true {
192
            defaults.set(AppData.currentCloudProfileSeedVersion, forKey: AppData.cloudProfileSeedVersionKey)
193
        }
194
    }
195

            
196
    private func migrateDeviceProfilesIfNeeded() {
197
        let defaults = UserDefaults.standard
198
        let installed = defaults.integer(forKey: AppData.cloudDeviceProfileMigrationVersionKey)
199
        guard installed < AppData.currentCloudDeviceProfileMigrationVersion else { return }
200

            
201
        if chargeInsightsStore?.migrateDevicesToProfiles() == true {
202
            defaults.set(AppData.currentCloudDeviceProfileMigrationVersion, forKey: AppData.cloudDeviceProfileMigrationVersionKey)
203
        }
204
    }
205

            
Bogdan Timofte authored 2 months ago
206
    func meterName(for macAddress: String) -> String? {
207
        meterStore.name(for: macAddress)
208
    }
209

            
210
    func setMeterName(_ name: String, for macAddress: String) {
211
        meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
212
    }
213

            
214
    func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
215
        let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
216
        return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
217
    }
218

            
219
    func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
220
        meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
Bogdan Timofte authored 2 months ago
221
    }
Bogdan Timofte authored 2 months ago
222

            
Bogdan Timofte authored 2 months ago
223
    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
224
        meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
Bogdan Timofte authored 2 months ago
225
    }
226

            
227
    func noteMeterSeen(at date: Date, macAddress: String) {
Bogdan Timofte authored a month ago
228
        if let persistedLastSeen = meterStore.lastSeen(for: macAddress),
229
           date.timeIntervalSince(persistedLastSeen) < meterPresencePersistInterval {
230
            return
231
        }
Bogdan Timofte authored 2 months ago
232
        meterStore.noteLastSeen(date, for: macAddress)
233
    }
234

            
235
    func noteMeterConnected(at date: Date, macAddress: String) {
236
        meterStore.noteLastConnected(date, for: macAddress)
237
    }
238

            
239
    func lastSeen(for macAddress: String) -> Date? {
240
        meterStore.lastSeen(for: macAddress)
241
    }
242

            
243
    func lastConnected(for macAddress: String) -> Date? {
244
        meterStore.lastConnected(for: macAddress)
245
    }
246

            
Bogdan Timofte authored a month ago
247
    func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
248
        chargedDevices.first(where: { $0.id == id })
249
    }
250

            
Bogdan Timofte authored a month ago
251
    func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
252
        for chargedDevice in chargedDevices {
253
            if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
254
                return session
255
            }
256
        }
257
        return nil
258
    }
259

            
Bogdan Timofte authored a month ago
260
    func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
261
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
262
        return chargedDevices.filter { chargedDevice in
263
            guard chargedDevice.isCharger == false else {
264
                return false
265
            }
Bogdan Timofte authored a month ago
266
            return chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
267
        }
268
    }
269

            
270
    func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] {
271
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
272
        return chargedDevices.filter { chargedDevice in
273
            guard chargedDevice.isCharger else {
274
                return false
275
            }
Bogdan Timofte authored a month ago
276
            return chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
277
        }
278
    }
279

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

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

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

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

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

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

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

            
322
        activeChargerStandbySessions[normalizedMAC] = session
323
        session.start()
324

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

            
330
        scheduleObjectWillChange()
331
        return true
332
    }
333

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

            
341
        session.stop()
342

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

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

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

            
362
        return didSave
363
    }
364

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

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

            
404
        if didSave {
405
            reloadChargedDevices()
406
        }
407

            
408
        return didSave
409
    }
410

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

            
423
        if didSave {
424
            reloadChargedDevices()
425
        }
426

            
427
        return didSave
428
    }
429

            
Bogdan Timofte authored a month ago
430
    @discardableResult
431
    func createPowerbank(
432
        name: String,
433
        templateID: String?,
434
        batteryLevelReporting: BatteryLevelReporting,
435
        batteryBarsCount: Int,
436
        notes: String?
437
    ) -> Bool {
438
        let didSave = chargeInsightsStore?.createPowerbank(
439
            name: name,
440
            templateID: templateID,
441
            batteryLevelReporting: batteryLevelReporting,
442
            batteryBarsCount: batteryBarsCount,
443
            notes: notes
444
        ) ?? false
445

            
446
        if didSave {
447
            reloadChargedDevices()
448
        }
449
        return didSave
450
    }
451

            
452
    @discardableResult
453
    func updatePowerbank(
454
        id: UUID,
455
        name: String,
456
        templateID: String?,
457
        batteryLevelReporting: BatteryLevelReporting,
458
        batteryBarsCount: Int,
459
        notes: String?
460
    ) -> Bool {
461
        let didSave = chargeInsightsStore?.updatePowerbank(
462
            id: id,
463
            name: name,
464
            templateID: templateID,
465
            batteryLevelReporting: batteryLevelReporting,
466
            batteryBarsCount: batteryBarsCount,
467
            notes: notes
468
        ) ?? false
469

            
470
        if didSave {
471
            reloadChargedDevices()
472
        }
473
        return didSave
474
    }
475

            
476
    @discardableResult
477
    func deletePowerbank(id: UUID) -> Bool {
478
        let didSave = chargeInsightsStore?.deletePowerbank(id: id) ?? false
479
        if didSave {
480
            reloadChargedDevices()
481
        }
482
        return didSave
483
    }
484

            
Bogdan Timofte authored a month ago
485
    @discardableResult
486
    func updateDevice(
Bogdan Timofte authored a month ago
487
        id: UUID,
488
        name: String,
489
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
490
        templateID: String?,
Bogdan Timofte authored a month ago
491
        profileID: String? = nil,
492
        hasInternalSubject: Bool = false,
Bogdan Timofte authored a month ago
493
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
494
        supportsWiredCharging: Bool,
495
        supportsWirelessCharging: Bool,
496
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
497
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
498
        notes: String?
499
    ) -> Bool {
Bogdan Timofte authored a month ago
500
        let didSave = chargeInsightsStore?.updateDevice(
Bogdan Timofte authored a month ago
501
            id: id,
502
            name: name,
503
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
504
            templateID: templateID,
Bogdan Timofte authored a month ago
505
            profileID: profileID,
506
            hasInternalSubject: hasInternalSubject,
Bogdan Timofte authored a month ago
507
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
508
            supportsWiredCharging: supportsWiredCharging,
509
            supportsWirelessCharging: supportsWirelessCharging,
510
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
511
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
512
            notes: notes
513
        ) ?? false
514

            
515
        if didSave {
516
            reloadChargedDevices()
517
        }
518

            
519
        return didSave
520
    }
521

            
522
    @discardableResult
Bogdan Timofte authored a month ago
523
    func updateCharger(
524
        id: UUID,
525
        name: String,
Bogdan Timofte authored a month ago
526
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
527
        notes: String?
528
    ) -> Bool {
529
        let didSave = chargeInsightsStore?.updateCharger(
530
            id: id,
531
            name: name,
Bogdan Timofte authored a month ago
532
            chargerType: chargerType,
Bogdan Timofte authored a month ago
533
            notes: notes
Bogdan Timofte authored a month ago
534
        ) ?? false
535

            
536
        if didSave {
537
            reloadChargedDevices()
538
        }
539

            
540
        return didSave
541
    }
542

            
Bogdan Timofte authored a month ago
543
    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
544
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
545
            return
546
        }
Bogdan Timofte authored a month ago
547
        guard activeSession.status.isOpen else {
Bogdan Timofte authored a month ago
548
            return
549
        }
550
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
551
    }
552

            
Bogdan Timofte authored a month ago
553
    @discardableResult
Bogdan Timofte authored a month ago
554
    func startChargeSession(
555
        for meter: Meter,
556
        chargedDeviceID: UUID,
557
        chargerID: UUID?,
Bogdan Timofte authored a month ago
558
        sourcePowerbankID: UUID? = nil,
Bogdan Timofte authored a month ago
559
        chargingTransportMode: ChargingTransportMode,
560
        chargingStateMode: ChargingStateMode,
561
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
562
        initialBatteryPercent: Double?,
563
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
564
    ) -> Bool {
Bogdan Timofte authored a month ago
565
        meter.resetMeterCountersForNewSession()
566

            
Bogdan Timofte authored a month ago
567
        guard let snapshot = meter.chargingMonitorSnapshot else {
568
            return false
569
        }
570

            
Bogdan Timofte authored a month ago
571
        let didSave = chargeInsightsStore?.startSession(
572
            for: snapshot,
573
            chargedDeviceID: chargedDeviceID,
574
            chargerID: chargerID,
Bogdan Timofte authored a month ago
575
            sourcePowerbankID: sourcePowerbankID,
Bogdan Timofte authored a month ago
576
            chargingTransportMode: chargingTransportMode,
577
            chargingStateMode: chargingStateMode,
578
            autoStopEnabled: autoStopEnabled,
Bogdan Timofte authored a month ago
579
            initialBatteryPercent: initialBatteryPercent,
580
            startsFromFlatBattery: startsFromFlatBattery
Bogdan Timofte authored a month ago
581
        ) ?? false
582
        if didSave {
Bogdan Timofte authored a month ago
583
            meter.resetChargeRecordGraph()
Bogdan Timofte authored a month ago
584
            let activeSession = chargeInsightsStore?.activeChargeSessionSummary(
585
                forMeterMACAddress: meter.btSerial.macAddress.description
586
            )
587
            if let activeSession,
Bogdan Timofte authored a month ago
588
               meter.supportsRecordingThreshold,
589
               activeSession.stopThresholdAmps > 0 {
590
                meter.recordingTreshold = activeSession.stopThresholdAmps
591
            }
Bogdan Timofte authored a month ago
592
            if let activeSession {
593
                meter.restoreChargeMonitoringIfNeeded(from: activeSession)
594
            }
595
            reloadChargedDevices()
Bogdan Timofte authored a month ago
596
        }
597
        return didSave
598
    }
599

            
600
    @discardableResult
601
    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
602
        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
Bogdan Timofte authored a month ago
603

            
604
        if let meter {
605
            _ = persistChargeSnapshot(from: meter, observedAt: observedAt)
606
        } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
607
            _ = flushPendingChargeObservation(for: meterMACAddress)
608
        }
609

            
Bogdan Timofte authored a month ago
610
        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
611
        if didSave {
612
            reloadChargedDevices()
613
        }
614
        return didSave
615
    }
616

            
617
    @discardableResult
618
    func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
619
        let snapshot = meter?.chargingMonitorSnapshot
620
        let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
621
        if didSave {
622
            reloadChargedDevices()
623
        }
624
        return didSave
625
    }
626

            
627
    @discardableResult
Bogdan Timofte authored a month ago
628
    func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil, from meter: Meter? = nil) -> Bool {
629
        if let meter {
630
            _ = persistChargeSnapshot(from: meter)
631
        } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
Bogdan Timofte authored a month ago
632
            _ = flushPendingChargeObservation(for: meterMACAddress)
633
        }
634

            
Bogdan Timofte authored a month ago
635
        let didSave = chargeInsightsStore?.stopSession(
636
            id: sessionID,
Bogdan Timofte authored a month ago
637
            finalBatteryPercent: finalBatteryPercent
Bogdan Timofte authored a month ago
638
        ) ?? false
Bogdan Timofte authored a month ago
639
        reloadChargedDevices()
Bogdan Timofte authored a month ago
640
        return didSave
641
    }
642

            
643
    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
644
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
645
            return
646
        }
647

            
Bogdan Timofte authored a month ago
648
        stageChargeObservation(snapshot)
Bogdan Timofte authored a month ago
649
    }
650

            
651
    @discardableResult
Bogdan Timofte authored a month ago
652
    func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
Bogdan Timofte authored a month ago
653
        _ = persistChargeSnapshot(from: meter)
Bogdan Timofte authored a month ago
654

            
655
        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
656
        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
657

            
Bogdan Timofte authored a month ago
658
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
659
            percent: percent,
Bogdan Timofte authored a month ago
660
            for: meter.btSerial.macAddress.description,
Bogdan Timofte authored a month ago
661
            measuredEnergyWh: checkpointEnergyWh
Bogdan Timofte authored a month ago
662
        ) ?? false
663

            
664
        if didSave {
665
            reloadChargedDevices()
666
        }
667

            
668
        return didSave
669
    }
670

            
671
    @discardableResult
Bogdan Timofte authored a month ago
672
    func addBatteryCheckpoint(percent: Double, for sessionID: UUID) -> Bool {
673
        guard canAddBatteryCheckpoint(to: sessionID) else {
674
            return false
675
        }
676

            
Bogdan Timofte authored a month ago
677
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
678
            percent: percent,
679
            for: sessionID
680
        ) ?? false
681

            
682
        if didSave {
683
            reloadChargedDevices()
684
        }
685

            
686
        return didSave
687
    }
688

            
Bogdan Timofte authored a month ago
689
    @discardableResult
690
    func addBatteryCheckpoint(
691
        percent: Double,
692
        for sessionID: UUID,
Bogdan Timofte authored a month ago
693
        measuredEnergyWh: Double?,
694
        subject: CheckpointSubject = .chargedDevice,
695
        barsValue: Int = 0
Bogdan Timofte authored a month ago
696
    ) -> Bool {
Bogdan Timofte authored a month ago
697
        guard canAddBatteryCheckpoint(to: sessionID) else {
698
            return false
699
        }
700

            
Bogdan Timofte authored a month ago
701
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
702
            percent: percent,
703
            for: sessionID,
Bogdan Timofte authored a month ago
704
            measuredEnergyWh: measuredEnergyWh,
705
            subject: subject,
706
            barsValue: barsValue
Bogdan Timofte authored a month ago
707
        ) ?? false
708

            
709
        if didSave {
710
            reloadChargedDevices()
711
        }
712

            
713
        return didSave
714
    }
715

            
Bogdan Timofte authored a month ago
716
    func canAddBatteryCheckpoint(to sessionID: UUID) -> Bool {
717
        guard let session = chargeSessionSummary(id: sessionID),
718
              session.status.isOpen,
719
              let meterMACAddress = session.meterMACAddress else {
720
            return false
721
        }
722

            
723
        return meter(for: meterMACAddress) != nil
724
    }
725

            
726
    func batteryCheckpointCaptureRequirementMessage(for sessionID: UUID) -> String? {
727
        guard let session = chargeSessionSummary(id: sessionID) else {
728
            return "Battery checkpoints are available only while the charge session is still active."
729
        }
730

            
731
        guard session.status.isOpen else {
732
            return "Battery checkpoints are available only while the charge session is still active."
733
        }
734

            
735
        guard let meterMACAddress = session.meterMACAddress,
736
              meter(for: meterMACAddress) != nil else {
737
            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."
738
        }
739

            
740
        return nil
741
    }
742

            
Bogdan Timofte authored a month ago
743
    func batteryCheckpointPlausibilityWarning(
744
        percent: Double,
Bogdan Timofte authored a month ago
745
        for sessionID: UUID,
746
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
747
    ) -> BatteryCheckpointPlausibilityWarning? {
748
        guard let session = chargeSessionSummary(id: sessionID) else {
749
            return nil
750
        }
Bogdan Timofte authored a month ago
751
        return batteryCheckpointPlausibilityWarning(
752
            percent: percent,
753
            for: session,
754
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
755
        )
Bogdan Timofte authored a month ago
756
    }
757

            
758
    @discardableResult
759
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
760
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
761
            id: checkpointID,
762
            from: sessionID
763
        ) ?? false
764

            
765
        if didDelete {
Bogdan Timofte authored a month ago
766
            reloadChargedDevices()
Bogdan Timofte authored a month ago
767
        }
768

            
769
        return didDelete
770
    }
771

            
Bogdan Timofte authored a month ago
772
    @discardableResult
773
    func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
774
        let didSave = chargeInsightsStore?.setSessionTrim(
775
            sessionID: sessionID,
776
            start: start,
777
            end: end
778
        ) ?? false
779
        if didSave {
780
            reloadChargedDevices()
781
        }
782
        return didSave
783
    }
784

            
Bogdan Timofte authored a month ago
785
    @discardableResult
786
    func commitSessionTrim(sessionID: UUID) -> Bool {
787
        let didSave = chargeInsightsStore?.commitSessionTrim(sessionID: sessionID) ?? false
788
        if didSave {
789
            reloadChargedDevices()
790
        }
791
        return didSave
792
    }
793

            
Bogdan Timofte authored a month ago
794
    @discardableResult
795
    func flushChargeInsights() -> Bool {
Bogdan Timofte authored a month ago
796
        let didFlushObservations = flushAllPendingChargeObservations()
Bogdan Timofte authored a month ago
797
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
Bogdan Timofte authored a month ago
798
        if didFlushObservations || didSave {
799
            reloadChargedDevices()
800
        }
801
        return didFlushObservations || didSave
Bogdan Timofte authored a month ago
802
    }
803

            
804
    @discardableResult
805
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
806
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
807
            return false
808
        }
809
        return setTargetBatteryPercent(percent, for: activeSession.id)
810
    }
811

            
812
    @discardableResult
813
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
814
        if percent != nil {
815
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
816
        }
817

            
818
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
819
        if didSave {
820
            reloadChargedDevices()
821
        }
822
        return didSave
823
    }
824

            
825
    @discardableResult
826
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
827
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
828
        if didSave {
829
            reloadChargedDevices()
830
        }
831
        return didSave
832
    }
833

            
834
    @discardableResult
835
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
836
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
837
        if didSave {
838
            reloadChargedDevices()
839
        }
840
        return didSave
841
    }
842

            
843
    @discardableResult
844
    func deleteChargeSession(sessionID: UUID) -> Bool {
845
        let deletedSession = chargedDevices
846
            .flatMap(\.sessions)
847
            .first(where: { $0.id == sessionID })
848

            
849
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
850
        guard didDelete else {
851
            return false
852
        }
853

            
Bogdan Timofte authored a month ago
854
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
855
           let meterMACAddress = deletedSession?.meterMACAddress,
856
           let liveMeter = meter(for: meterMACAddress) {
857
            liveMeter.resetChargeRecord()
858
        }
859

            
860
        reloadChargedDevices()
861
        return true
862
    }
863

            
864
    @discardableResult
865
    func deleteChargedDevice(id: UUID) -> Bool {
866
        let deletedDevice = chargedDeviceSummary(id: id)
867
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
868
        guard didDelete else {
869
            return false
870
        }
871

            
Bogdan Timofte authored a month ago
872
        if deletedDevice?.isCharger == true {
873
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
874
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
875
                session.stop()
876
                activeChargerStandbySessions[meterMACAddress] = nil
877
            }
878
        }
879

            
Bogdan Timofte authored a month ago
880
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
881
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
882
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
883
           let liveMeter = meter(for: meterMACAddress) {
884
            liveMeter.resetChargeRecord()
885
        }
886

            
887
        reloadChargedDevices()
888
        return true
889
    }
890

            
891
    @discardableResult
892
    func createKnownMeter(
893
        macAddress: String,
894
        customName: String?,
895
        modelName: String,
896
        advertisedName: String?
897
    ) -> Bool {
898
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
899
        guard Self.isValidMACAddress(normalizedMAC) else {
900
            return false
901
        }
902

            
903
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
904
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
905
            setMeterName(customName, for: normalizedMAC)
906
        }
907
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
908
        return true
909
    }
910

            
911
    @discardableResult
912
    func deleteMeter(macAddress: String) -> Bool {
913
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
914
        guard Self.isValidMACAddress(normalizedMAC) else {
915
            return false
916
        }
917

            
918
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
919
            meter.disconnect()
920
        }
921
        meters = meters.filter { element in
922
            element.value.btSerial.macAddress.description != normalizedMAC
923
        }
924

            
925
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
926
        if didDelete {
927
            scheduleObjectWillChange()
928
        }
929
        return didDelete
930
    }
931

            
Bogdan Timofte authored 2 months ago
932
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored a month ago
933
        if let meterSummariesCache, meterSummariesCache.version == meterSummariesVersion {
934
            return meterSummariesCache.summaries
935
        }
936

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

            
Bogdan Timofte authored a month ago
941
        let summaries = macAddresses.map { macAddress in
Bogdan Timofte authored 2 months ago
942
            let liveMeter = liveMetersByMAC[macAddress]
943
            let record = recordsByMAC[macAddress]
944

            
Bogdan Timofte authored 2 months ago
945
            return MeterSummary(
Bogdan Timofte authored 2 months ago
946
                macAddress: macAddress,
Bogdan Timofte authored 2 months ago
947
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
Bogdan Timofte authored 2 months ago
948
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
Bogdan Timofte authored 2 months ago
949
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
950
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
951
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
952
                meter: liveMeter
953
            )
954
        }
955
        .sorted { lhs, rhs in
Bogdan Timofte authored 2 months ago
956
            if lhs.meter != nil && rhs.meter == nil {
957
                return true
958
            }
959
            if lhs.meter == nil && rhs.meter != nil {
960
                return false
961
            }
Bogdan Timofte authored 2 months ago
962
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
963
            if byName != .orderedSame {
964
                return byName == .orderedAscending
965
            }
966
            return lhs.macAddress < rhs.macAddress
967
        }
Bogdan Timofte authored a month ago
968

            
969
        meterSummariesCache = (version: meterSummariesVersion, summaries: summaries)
970
        return summaries
Bogdan Timofte authored 2 months ago
971
    }
972

            
Bogdan Timofte authored 2 months ago
973
    private func scheduleObjectWillChange() {
974
        DispatchQueue.main.async { [weak self] in
975
            self?.objectWillChange.send()
976
        }
977
    }
Bogdan Timofte authored 2 months ago
978

            
Bogdan Timofte authored a month ago
979
    private func invalidateMeterSummaries() {
980
        meterSummariesVersion += 1
981
        meterSummariesCache = nil
982
    }
983

            
Bogdan Timofte authored a month ago
984
    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
985
        pendingChargedDevicesReloadWorkItem?.cancel()
986

            
987
        let workItem = DispatchWorkItem { [weak self] in
988
            self?.reloadChargedDevices()
989
        }
990
        pendingChargedDevicesReloadWorkItem = workItem
991
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
992
    }
993

            
Bogdan Timofte authored a month ago
994
    private func stageChargeObservation(_ snapshot: ChargingMonitorSnapshot, scheduleFlush: Bool = true) {
995
        let normalizedMAC = Self.normalizedMACAddress(snapshot.meterMACAddress)
996
        guard !normalizedMAC.isEmpty else {
997
            return
998
        }
999

            
1000
        pendingChargeObservationSnapshots[normalizedMAC] = snapshot
1001

            
1002
        guard scheduleFlush else {
1003
            return
1004
        }
1005

            
1006
        guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
1007
            return
1008
        }
1009

            
1010
        let workItem = DispatchWorkItem { [weak self] in
1011
            guard let self else { return }
1012
            self.pendingChargeObservationWorkItems[normalizedMAC] = nil
1013
            guard let snapshot = self.pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
1014
                return
1015
            }
1016
            // CoreData write on background — DidSave observer handles the reload
1017
            let store = self.chargeInsightsStore
1018
            DispatchQueue.global(qos: .utility).async {
1019
                store?.observe(snapshot: snapshot)
1020
            }
1021
        }
1022
        pendingChargeObservationWorkItems[normalizedMAC] = workItem
1023
        DispatchQueue.main.asyncAfter(deadline: .now() + chargeObservationPersistInterval, execute: workItem)
1024
    }
1025

            
1026
    @discardableResult
1027
    private func persistChargeSnapshot(from meter: Meter, observedAt: Date = Date()) -> Bool {
1028
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
1029
            return false
1030
        }
1031

            
1032
        stageChargeObservation(snapshot, scheduleFlush: false)
1033
        return flushPendingChargeObservation(for: snapshot.meterMACAddress)
1034
    }
1035

            
1036
    @discardableResult
1037
    private func flushPendingChargeObservation(for meterMACAddress: String) -> Bool {
1038
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
1039
        guard !normalizedMAC.isEmpty else {
1040
            return false
1041
        }
1042

            
1043
        pendingChargeObservationWorkItems[normalizedMAC]?.cancel()
1044
        pendingChargeObservationWorkItems[normalizedMAC] = nil
1045

            
1046
        guard let snapshot = pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
1047
            return false
1048
        }
1049

            
1050
        let didSave = chargeInsightsStore?.observe(snapshot: snapshot) ?? false
1051
        return didSave
1052
    }
1053

            
1054
    @discardableResult
1055
    private func flushAllPendingChargeObservations() -> Bool {
1056
        let pendingMeterMACAddresses = Array(pendingChargeObservationSnapshots.keys)
1057
        var didSave = false
1058

            
1059
        for meterMACAddress in pendingMeterMACAddresses {
1060
            if flushPendingChargeObservation(for: meterMACAddress) {
1061
                didSave = true
1062
            }
1063
        }
1064

            
1065
        return didSave
1066
    }
1067

            
Bogdan Timofte authored a month ago
1068
    private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
1069
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
1070
        guard !normalizedMAC.isEmpty else {
1071
            return nil
1072
        }
1073

            
1074
        return chargedDevices
1075
            .lazy
1076
            .compactMap(\.activeSession)
Bogdan Timofte authored a month ago
1077
            .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
1078
    }
1079

            
Bogdan Timofte authored a month ago
1080
    @discardableResult
1081
    private func healDuplicateOpenSessions() -> Bool {
1082
        chargeInsightsStore?.healDuplicateOpenSessions() ?? false
1083
    }
1084

            
Bogdan Timofte authored a month ago
1085
    @discardableResult
1086
    private func expireOverlongChargeSessionsIfNeeded(referenceDate: Date = Date()) -> Bool {
1087
        chargeInsightsStore?.completeExpiredOpenSessions(referenceDate: referenceDate) ?? false
1088
    }
1089

            
Bogdan Timofte authored a month ago
1090
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
1091
        if Thread.isMainThread == false {
1092
            DispatchQueue.main.async { [weak self] in
1093
                self?.reloadChargedDevices()
1094
            }
1095
            return
1096
        }
1097

            
Bogdan Timofte authored a month ago
1098
        pendingChargedDevicesReloadWorkItem?.cancel()
1099
        pendingChargedDevicesReloadWorkItem = nil
1100

            
Bogdan Timofte authored a month ago
1101
        _ = healDuplicateOpenSessions()
Bogdan Timofte authored a month ago
1102
        _ = expireOverlongChargeSessionsIfNeeded()
1103

            
Bogdan Timofte authored a month ago
1104
        guard chargedDevicesReloadInFlight == false else {
1105
            chargedDevicesReloadPending = true
1106
            return
1107
        }
1108

            
Bogdan Timofte authored a month ago
1109
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
Bogdan Timofte authored a month ago
1110
        let readStore = chargeInsightsReadStore ?? chargeInsightsStore
Bogdan Timofte authored a month ago
1111
        chargedDevicesReloadInFlight = true
1112
        chargedDevicesReloadPending = false
Bogdan Timofte authored a month ago
1113

            
1114
        chargedDevicesReloadQueue.async { [weak self] in
1115
            guard let self else { return }
1116

            
1117
            readStore?.resetContext()
1118
            let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
1119
                chargedDevice.withStandbyPowerMeasurements(
1120
                    standbyMeasurementsByChargerID[chargedDevice.id] ?? []
1121
                )
1122
            }
Bogdan Timofte authored a month ago
1123
            let powerbankSummaries = readStore?.fetchPowerbankSummaries() ?? []
Bogdan Timofte authored a month ago
1124

            
1125
            DispatchQueue.main.async { [weak self] in
1126
                guard let self else { return }
1127

            
1128
                self.chargedDevices = summaries
Bogdan Timofte authored a month ago
1129
                self.powerbanks = powerbankSummaries
Bogdan Timofte authored a month ago
1130
                self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
1131
                for meter in self.meters.values {
1132
                    self.restoreChargeMonitoringStateIfNeeded(for: meter)
1133
                }
Bogdan Timofte authored a month ago
1134

            
1135
                self.chargedDevicesReloadInFlight = false
1136
                if self.chargedDevicesReloadPending {
1137
                    self.reloadChargedDevices()
1138
                }
Bogdan Timofte authored a month ago
1139
            }
Bogdan Timofte authored a month ago
1140
        }
1141
    }
1142

            
1143
    private func meter(for meterMACAddress: String) -> Meter? {
1144
        meters.values.first { meter in
1145
            meter.btSerial.macAddress.description == meterMACAddress
1146
        }
1147
    }
1148

            
Bogdan Timofte authored 2 months ago
1149
    private func refreshMeterMetadata() {
1150
        DispatchQueue.main.async { [weak self] in
1151
            guard let self else { return }
1152
            var didUpdateAnyMeter = false
1153
            for meter in self.meters.values {
1154
                let mac = meter.btSerial.macAddress.description
1155
                let displayName = self.meterName(for: mac) ?? mac
1156
                if meter.name != displayName {
1157
                    meter.updateNameFromStore(displayName)
1158
                    didUpdateAnyMeter = true
1159
                }
1160

            
1161
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
1162
                meter.reloadTemperatureUnitPreference()
1163
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
1164
                    didUpdateAnyMeter = true
1165
                }
1166
            }
1167

            
1168
            if didUpdateAnyMeter {
1169
                self.scheduleObjectWillChange()
1170
            }
1171
        }
1172
    }
Bogdan Timofte authored a month ago
1173

            
Bogdan Timofte authored a month ago
1174
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
1175
        guard let charger = chargedDeviceSummary(id: session.chargerID),
1176
              let statistics = session.statistics else {
1177
            return
1178
        }
1179

            
1180
        let content = UNMutableNotificationContent()
1181
        content.title = "Standby baseline stabilised"
1182
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
1183
        content.sound = .default
1184
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
1185

            
1186
        let request = UNNotificationRequest(
1187
            identifier: "charger-standby-\(session.id.uuidString)",
1188
            content: content,
1189
            trigger: nil
1190
        )
1191
        UNUserNotificationCenter.current().add(request)
1192
        scheduleObjectWillChange()
1193
    }
1194

            
Bogdan Timofte authored a month ago
1195
    private func batteryCheckpointPlausibilityWarning(
1196
        percent: Double,
Bogdan Timofte authored a month ago
1197
        for session: ChargeSessionSummary,
1198
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
1199
    ) -> BatteryCheckpointPlausibilityWarning? {
1200
        guard percent.isFinite, percent >= 0, percent <= 100 else {
1201
            return nil
1202
        }
1203

            
1204
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
1205
            if lhs.timestamp != rhs.timestamp {
1206
                return lhs.timestamp < rhs.timestamp
1207
            }
1208
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1209
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1210
            }
1211
            return lhs.id.uuidString < rhs.id.uuidString
1212
        }
1213

            
1214
        if let lastCheckpoint = sortedCheckpoints.last,
1215
           percent < lastCheckpoint.batteryPercent - 1.5 {
1216
            return BatteryCheckpointPlausibilityWarning(
1217
                title: "Checkpoint Goes Backwards",
1218
                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."
1219
            )
1220
        }
1221

            
Bogdan Timofte authored a month ago
1222
        let effectiveEnergyWh = effectiveEnergyWhOverride
1223
            ?? session.effectiveBatteryEnergyWh
1224
            ?? session.measuredEnergyWh
1225

            
1226
        if let lastCheckpoint = sortedCheckpoints.last,
1227
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
1228
            let estimatedCapacityWh = session.capacityEstimateWh
1229
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1230
                ?? chargedDevice.estimatedBatteryCapacityWh
1231

            
1232
            if let estimatedCapacityWh, estimatedCapacityWh > 0 {
1233
                let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1234
                let expectedPercent = min(
1235
                    100,
1236
                    max(
1237
                        lastCheckpoint.batteryPercent,
1238
                        lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
1239
                    )
1240
                )
1241
                let predictionGap = percent - expectedPercent
1242
                guard abs(predictionGap) >= 4 else {
1243
                    return nil
1244
                }
1245

            
1246
                let direction = predictionGap > 0 ? "above" : "below"
1247
                let gapText = abs(predictionGap).format(decimalDigits: 0)
1248
                let expectedText = expectedPercent.format(decimalDigits: 0)
1249

            
1250
                return BatteryCheckpointPlausibilityWarning(
1251
                    title: "Checkpoint Looks Implausible",
1252
                    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."
1253
                )
1254
            }
1255
        }
1256

            
Bogdan Timofte authored a month ago
1257
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
Bogdan Timofte authored a month ago
1258
              let prediction = chargedDevice.batteryLevelPrediction(
1259
                for: session,
1260
                effectiveEnergyWhOverride: effectiveEnergyWh
1261
              )
Bogdan Timofte authored a month ago
1262
        else {
1263
            return nil
1264
        }
1265

            
1266
        let predictionGap = percent - prediction.predictedPercent
1267
        guard abs(predictionGap) >= 4 else {
1268
            return nil
1269
        }
1270

            
1271
        let direction = predictionGap > 0 ? "above" : "below"
1272
        let gapText = abs(predictionGap).format(decimalDigits: 0)
1273
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
1274

            
1275
        if let lastCheckpoint = sortedCheckpoints.last {
1276
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1277
            return BatteryCheckpointPlausibilityWarning(
1278
                title: "Checkpoint Looks Implausible",
1279
                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."
1280
            )
1281
        }
1282

            
1283
        return BatteryCheckpointPlausibilityWarning(
1284
            title: "Checkpoint Looks Implausible",
1285
            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."
1286
        )
1287
    }
Bogdan Timofte authored a month ago
1288

            
1289
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1290
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
Bogdan Timofte authored a month ago
1291
        guard session.isTrimmed == false else {
1292
            return storedEnergyWh
1293
        }
Bogdan Timofte authored a month ago
1294
        guard session.status.isOpen else {
1295
            return storedEnergyWh
1296
        }
1297

            
1298
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1299
            return storedEnergyWh
1300
        }
1301

            
1302
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1303
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
1304
        }
1305

            
1306
        return storedEnergyWh
1307
    }
1308

            
Bogdan Timofte authored 2 months ago
1309
}
Bogdan Timofte authored 2 months ago
1310

            
Bogdan Timofte authored a month ago
1311

            
Bogdan Timofte authored 2 months ago
1312
extension AppData.MeterSummary {
1313
    var tint: Color {
1314
        switch modelSummary {
1315
        case "UM25C":
1316
            return .blue
1317
        case "UM34C":
1318
            return .yellow
1319
        case "TC66C":
1320
            return Model.TC66C.color
1321
        default:
1322
            return .secondary
1323
        }
1324
    }
1325
}
Bogdan Timofte authored 2 months ago
1326

            
Bogdan Timofte authored a month ago
1327
extension AppData {
Bogdan Timofte authored 2 months ago
1328
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
1329
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
1330
            return liveName
1331
        }
1332
        if let customName = record?.customName {
1333
            return customName
1334
        }
1335
        if let advertisedName = record?.advertisedName {
1336
            return advertisedName
1337
        }
1338
        if let recordModel = record?.modelName {
1339
            return recordModel
1340
        }
1341
        if let liveModel = liveMeter?.deviceModelSummary {
1342
            return liveModel
1343
        }
1344
        return "Meter"
1345
    }
Bogdan Timofte authored a month ago
1346

            
1347
    static func normalizedMACAddress(_ macAddress: String) -> String {
1348
        macAddress
1349
            .trimmingCharacters(in: .whitespacesAndNewlines)
1350
            .uppercased()
1351
    }
1352

            
1353
    static func isValidMACAddress(_ macAddress: String) -> Bool {
1354
        macAddress.range(
1355
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
1356
            options: .regularExpression
1357
        ) != nil
1358
    }
1359
}
1360

            
1361
private final class ChargeNotificationCoordinator {
1362
    private struct Payload {
1363
        let id: String
1364
        let title: String
1365
        let body: String
1366
        let threadIdentifier: String
1367
    }
1368

            
1369
    private let notificationCenter = UNUserNotificationCenter.current()
1370
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
1371
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
1372
    private var inFlightEventIDs: Set<String> = []
1373

            
1374
    func ensureAuthorizationIfNeeded() {
1375
        notificationCenter.getNotificationSettings { [weak self] settings in
1376
            guard settings.authorizationStatus == .notDetermined else {
1377
                return
1378
            }
1379

            
1380
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
1381
                if let error {
1382
                    track("Notification authorization request failed: \(error.localizedDescription)")
1383
                }
1384
            }
1385
        }
1386
    }
1387

            
1388
    func process(chargedDevices: [ChargedDeviceSummary]) {
1389
        let now = Date()
1390
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
1391
            payloads(for: chargedDevice, now: now)
1392
        }
1393

            
1394
        for payload in pendingPayloads {
1395
            scheduleIfNeeded(payload)
1396
        }
1397
    }
1398

            
1399
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1400
        chargedDevice.sessions.compactMap { session in
1401
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1402
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1403
               let targetBatteryPercent = session.targetBatteryPercent {
1404
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1405
                    ?? session.endBatteryPercent
1406
                    ?? targetBatteryPercent
1407

            
1408
                return Payload(
1409
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1410
                    title: "Battery target reached",
1411
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1412
                    threadIdentifier: session.id.uuidString
1413
                )
1414
            }
1415

            
1416
            if session.requiresCompletionConfirmation,
1417
               let requestedAt = session.completionConfirmationRequestedAt,
1418
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
1419
                let estimatedPercent = session.completionContradictionPercent
1420
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1421
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
1422
                let detail = estimatedPercent.map {
1423
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
1424
                } ?? ""
1425

            
1426
                return Payload(
1427
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1428
                    title: "Confirm charge completion",
1429
                    body: bodyPrefix + detail,
1430
                    threadIdentifier: session.id.uuidString
1431
                )
1432
            }
1433

            
1434
            return nil
1435
        }
1436
    }
1437

            
1438
    private func scheduleIfNeeded(_ payload: Payload) {
1439
        guard deliveredEventIDs().contains(payload.id) == false else {
1440
            return
1441
        }
1442

            
1443
        guard inFlightEventIDs.contains(payload.id) == false else {
1444
            return
1445
        }
1446

            
1447
        inFlightEventIDs.insert(payload.id)
1448

            
1449
        notificationCenter.getNotificationSettings { [weak self] settings in
1450
            guard let self else { return }
1451
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1452
                DispatchQueue.main.async {
1453
                    self.inFlightEventIDs.remove(payload.id)
1454
                }
1455
                return
1456
            }
1457

            
1458
            let content = UNMutableNotificationContent()
1459
            content.title = payload.title
1460
            content.body = payload.body
1461
            content.sound = .default
1462
            content.threadIdentifier = payload.threadIdentifier
1463

            
1464
            let request = UNNotificationRequest(
1465
                identifier: payload.id,
1466
                content: content,
1467
                trigger: nil
1468
            )
1469

            
1470
            self.notificationCenter.add(request) { error in
1471
                DispatchQueue.main.async {
1472
                    self.inFlightEventIDs.remove(payload.id)
1473
                    if let error {
1474
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1475
                        return
1476
                    }
1477
                    self.storeDeliveredEventID(payload.id)
1478
                }
1479
            }
1480
        }
1481
    }
1482

            
1483
    private func deliveredEventIDs() -> Set<String> {
1484
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1485
        return Set(values)
1486
    }
1487

            
1488
    private func storeDeliveredEventID(_ id: String) {
1489
        var values = deliveredEventIDs()
1490
        values.insert(id)
1491
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1492
    }
Bogdan Timofte authored 2 months ago
1493
}