Newer Older
932 lines | 32.999kb
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 2 months ago
44
    private let meterStore = MeterNameStore.shared
Bogdan Timofte authored a month ago
45
    private var chargeInsightsStore: ChargeInsightsStore?
46
    private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
Bogdan Timofte authored 2 months ago
47

            
Bogdan Timofte authored 2 months ago
48
    init() {
Bogdan Timofte authored 2 months ago
49
        bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
Bogdan Timofte authored 2 months ago
50
            self?.scheduleObjectWillChange()
Bogdan Timofte authored 2 months ago
51
        }
Bogdan Timofte authored 2 months ago
52
        meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange)
53
            .receive(on: DispatchQueue.main)
54
            .sink { [weak self] _ in
55
                self?.refreshMeterMetadata()
56
            }
57
        meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange)
58
            .receive(on: DispatchQueue.main)
59
            .sink { [weak self] _ in
60
                self?.scheduleObjectWillChange()
61
            }
Bogdan Timofte authored 2 months ago
62
    }
Bogdan Timofte authored 2 months ago
63

            
Bogdan Timofte authored 2 months ago
64
    let bluetoothManager = BluetoothManager()
Bogdan Timofte authored 2 months ago
65

            
Bogdan Timofte authored 2 months ago
66
    @Published var enableRecordFeature: Bool = true
Bogdan Timofte authored 2 months ago
67

            
Bogdan Timofte authored 2 months ago
68
    @Published var meters: [UUID:Meter] = [UUID:Meter]()
Bogdan Timofte authored a month ago
69
    @Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
70

            
71
    var deviceSummaries: [ChargedDeviceSummary] {
72
        chargedDevices.filter { !$0.isCharger }
73
    }
74

            
75
    var chargerSummaries: [ChargedDeviceSummary] {
76
        chargedDevices.filter { $0.isCharger }
77
    }
Bogdan Timofte authored 2 months ago
78

            
79
    var cloudAvailability: MeterNameStore.CloudAvailability {
80
        meterStore.currentCloudAvailability
81
    }
82

            
Bogdan Timofte authored a month ago
83
    func activateChargeInsights(context: NSManagedObjectContext) {
84
        guard chargeInsightsStore == nil else {
85
            return
86
        }
87

            
88
        context.automaticallyMergesChangesFromParent = true
Bogdan Timofte authored a month ago
89
        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
Bogdan Timofte authored a month ago
90
        chargeInsightsStore = ChargeInsightsStore(context: context)
91

            
92
        chargeInsightsStoreObserver = NotificationCenter.default.publisher(
93
            for: .NSManagedObjectContextObjectsDidChange,
94
            object: context
95
        )
96
        .receive(on: DispatchQueue.main)
97
        .sink { [weak self] _ in
98
            self?.reloadChargedDevices()
99
        }
100

            
101
        chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
102
            for: .NSPersistentStoreRemoteChange,
103
            object: nil
104
        )
105
        .receive(on: DispatchQueue.main)
106
        .sink { [weak self] _ in
107
            self?.reloadChargedDevices()
108
        }
109

            
110
        chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
111
        reloadChargedDevices()
112
    }
113

            
Bogdan Timofte authored 2 months ago
114
    func meterName(for macAddress: String) -> String? {
115
        meterStore.name(for: macAddress)
116
    }
117

            
118
    func setMeterName(_ name: String, for macAddress: String) {
119
        meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
120
    }
121

            
122
    func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
123
        let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
124
        return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
125
    }
126

            
127
    func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
128
        meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
Bogdan Timofte authored 2 months ago
129
    }
Bogdan Timofte authored 2 months ago
130

            
Bogdan Timofte authored 2 months ago
131
    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
132
        meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
Bogdan Timofte authored 2 months ago
133
    }
134

            
135
    func noteMeterSeen(at date: Date, macAddress: String) {
136
        meterStore.noteLastSeen(date, for: macAddress)
137
    }
138

            
139
    func noteMeterConnected(at date: Date, macAddress: String) {
140
        meterStore.noteLastConnected(date, for: macAddress)
141
    }
142

            
143
    func lastSeen(for macAddress: String) -> Date? {
144
        meterStore.lastSeen(for: macAddress)
145
    }
146

            
147
    func lastConnected(for macAddress: String) -> Date? {
148
        meterStore.lastConnected(for: macAddress)
149
    }
150

            
Bogdan Timofte authored a month ago
151
    func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
152
        chargedDevices.first(where: { $0.id == id })
153
    }
154

            
Bogdan Timofte authored a month ago
155
    func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
156
        for chargedDevice in chargedDevices {
157
            if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
158
                return session
159
            }
160
        }
161
        return nil
162
    }
163

            
Bogdan Timofte authored a month ago
164
    func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
165
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
166
        return chargedDevices.filter { chargedDevice in
167
            guard chargedDevice.isCharger == false else {
168
                return false
169
            }
170
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
171
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
172
        }
173
    }
174

            
175
    func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] {
176
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
177
        return chargedDevices.filter { chargedDevice in
178
            guard chargedDevice.isCharger else {
179
                return false
180
            }
181
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
182
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
183
        }
184
    }
185

            
186
    func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
187
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
188

            
189
        if let activeSession = activeChargeSessionSummary(for: normalizedMAC),
190
           let liveDevice = chargedDevices.first(where: {
191
               $0.id == activeSession.chargedDeviceID && $0.isCharger == false
192
           }) {
193
            return liveDevice
194
        }
195

            
196
        return chargedDevices.first(where: {
197
            $0.isCharger == false && $0.lastAssociatedMeterMAC == normalizedMAC
198
        })
199
    }
200

            
201
    func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
202
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
203

            
204
        if let activeSession = activeChargeSessionSummary(for: normalizedMAC),
205
           let chargerID = activeSession.chargerID,
206
           let liveCharger = chargedDevices.first(where: {
207
               $0.id == chargerID && $0.isCharger
208
           }) {
209
            return liveCharger
210
        }
211

            
212
        return chargedDevices.first(where: {
213
            $0.isCharger && $0.lastAssociatedMeterMAC == normalizedMAC
214
        })
215
    }
216

            
217
    func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
218
        chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
219
    }
220

            
221
    @discardableResult
Bogdan Timofte authored a month ago
222
    func createDevice(
Bogdan Timofte authored a month ago
223
        name: String,
224
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
225
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
226
        supportsWiredCharging: Bool,
227
        supportsWirelessCharging: Bool,
228
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
229
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
230
        notes: String?,
231
        meterMACAddress: String?
232
    ) -> Bool {
Bogdan Timofte authored a month ago
233
        let didSave = chargeInsightsStore?.createDevice(
Bogdan Timofte authored a month ago
234
            name: name,
235
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
236
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
237
            supportsWiredCharging: supportsWiredCharging,
238
            supportsWirelessCharging: supportsWirelessCharging,
239
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
240
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
241
            notes: notes,
242
            assignTo: meterMACAddress
243
        ) ?? false
244

            
245
        if didSave {
246
            reloadChargedDevices()
247
        }
248

            
249
        return didSave
250
    }
251

            
252
    @discardableResult
Bogdan Timofte authored a month ago
253
    func createCharger(
254
        name: String,
255
        notes: String?,
256
        meterMACAddress: String?
257
    ) -> Bool {
258
        let didSave = chargeInsightsStore?.createCharger(
259
            name: name,
260
            notes: notes,
261
            assignTo: meterMACAddress
262
        ) ?? false
263

            
264
        if didSave {
265
            reloadChargedDevices()
266
        }
267

            
268
        return didSave
269
    }
270

            
271
    @discardableResult
272
    func updateDevice(
Bogdan Timofte authored a month ago
273
        id: UUID,
274
        name: String,
275
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
276
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
277
        supportsWiredCharging: Bool,
278
        supportsWirelessCharging: Bool,
279
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
280
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
281
        notes: String?
282
    ) -> Bool {
Bogdan Timofte authored a month ago
283
        let didSave = chargeInsightsStore?.updateDevice(
Bogdan Timofte authored a month ago
284
            id: id,
285
            name: name,
286
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
287
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
288
            supportsWiredCharging: supportsWiredCharging,
289
            supportsWirelessCharging: supportsWirelessCharging,
290
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
291
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
292
            notes: notes
293
        ) ?? false
294

            
295
        if didSave {
296
            reloadChargedDevices()
297
        }
298

            
299
        return didSave
300
    }
301

            
302
    @discardableResult
Bogdan Timofte authored a month ago
303
    func updateCharger(
304
        id: UUID,
305
        name: String,
306
        notes: String?
307
    ) -> Bool {
308
        let didSave = chargeInsightsStore?.updateCharger(
309
            id: id,
310
            name: name,
311
            notes: notes
Bogdan Timofte authored a month ago
312
        ) ?? false
313

            
314
        if didSave {
315
            reloadChargedDevices()
316
        }
317

            
318
        return didSave
319
    }
320

            
321
    @discardableResult
322
    func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool {
323
        let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false
324
        if didSave {
325
            reloadChargedDevices()
326
        }
327
        return didSave
328
    }
329

            
330
    @discardableResult
331
    func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
332
        let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false
333
        if didSave {
334
            reloadChargedDevices()
335
        }
336
        return didSave
337
    }
338

            
Bogdan Timofte authored a month ago
339
    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
340
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
341
            return
342
        }
343
        guard activeSession.status == .active else {
344
            return
345
        }
346
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
347
    }
348

            
Bogdan Timofte authored a month ago
349
    @discardableResult
Bogdan Timofte authored a month ago
350
    func startChargeSession(
351
        for meter: Meter,
352
        chargedDeviceID: UUID,
353
        chargerID: UUID?,
354
        chargingTransportMode: ChargingTransportMode,
355
        chargingStateMode: ChargingStateMode,
356
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
357
        initialBatteryPercent: Double?,
358
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
359
    ) -> Bool {
Bogdan Timofte authored a month ago
360
        guard let snapshot = meter.chargingMonitorSnapshot else {
361
            return false
362
        }
363

            
Bogdan Timofte authored a month ago
364
        let didSave = chargeInsightsStore?.startSession(
365
            for: snapshot,
366
            chargedDeviceID: chargedDeviceID,
367
            chargerID: chargerID,
368
            chargingTransportMode: chargingTransportMode,
369
            chargingStateMode: chargingStateMode,
370
            autoStopEnabled: autoStopEnabled,
Bogdan Timofte authored a month ago
371
            initialBatteryPercent: initialBatteryPercent,
372
            startsFromFlatBattery: startsFromFlatBattery
Bogdan Timofte authored a month ago
373
        ) ?? false
374
        if didSave {
375
            reloadChargedDevices()
Bogdan Timofte authored a month ago
376
            meter.resetChargeRecordGraph()
377
            if let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description),
378
               meter.supportsRecordingThreshold,
379
               activeSession.stopThresholdAmps > 0 {
380
                meter.recordingTreshold = activeSession.stopThresholdAmps
381
            }
382
            restoreChargeMonitoringStateIfNeeded(for: meter)
Bogdan Timofte authored a month ago
383
        }
384
        return didSave
385
    }
386

            
387
    @discardableResult
388
    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
389
        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
390
        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
391
        if didSave {
392
            reloadChargedDevices()
393
        }
394
        return didSave
395
    }
396

            
397
    @discardableResult
398
    func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
399
        let snapshot = meter?.chargingMonitorSnapshot
400
        let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
401
        if didSave {
402
            reloadChargedDevices()
403
        }
404
        return didSave
405
    }
406

            
407
    @discardableResult
408
    func stopChargeSession(
409
        sessionID: UUID,
410
        finalBatteryPercent: Double,
411
        label: String? = "Final"
412
    ) -> Bool {
413
        let didSave = chargeInsightsStore?.stopSession(
414
            id: sessionID,
415
            finalBatteryPercent: finalBatteryPercent,
416
            label: label
417
        ) ?? false
Bogdan Timofte authored a month ago
418
        if didSave {
419
            reloadChargedDevices()
420
        }
421
        return didSave
422
    }
423

            
424
    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
425
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
426
            return
427
        }
428

            
429
        if chargeInsightsStore?.observe(snapshot: snapshot) == true {
430
            reloadChargedDevices()
431
        }
432
    }
433

            
434
    @discardableResult
435
    func addBatteryCheckpoint(percent: Double, label: String?, for meter: Meter) -> Bool {
436
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
437
            percent: percent,
438
            label: label,
439
            for: meter.btSerial.macAddress.description
440
        ) ?? false
441

            
442
        if didSave {
443
            reloadChargedDevices()
444
        }
445

            
446
        return didSave
447
    }
448

            
449
    @discardableResult
450
    func addBatteryCheckpoint(percent: Double, label: String?, for sessionID: UUID) -> Bool {
451
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
452
            percent: percent,
453
            label: label,
454
            for: sessionID
455
        ) ?? false
456

            
457
        if didSave {
458
            reloadChargedDevices()
459
        }
460

            
461
        return didSave
462
    }
463

            
Bogdan Timofte authored a month ago
464
    func batteryCheckpointPlausibilityWarning(
465
        percent: Double,
466
        for sessionID: UUID
467
    ) -> BatteryCheckpointPlausibilityWarning? {
468
        guard let session = chargeSessionSummary(id: sessionID) else {
469
            return nil
470
        }
471
        return batteryCheckpointPlausibilityWarning(percent: percent, for: session)
472
    }
473

            
474
    @discardableResult
475
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
476
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
477
            id: checkpointID,
478
            from: sessionID
479
        ) ?? false
480

            
481
        if didDelete {
482
            reloadChargedDevices()
483
        }
484

            
485
        return didDelete
486
    }
487

            
Bogdan Timofte authored a month ago
488
    @discardableResult
489
    func flushChargeInsights() -> Bool {
490
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
491
        reloadChargedDevices()
492
        return didSave
493
    }
494

            
495
    @discardableResult
496
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
497
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
498
            return false
499
        }
500
        return setTargetBatteryPercent(percent, for: activeSession.id)
501
    }
502

            
503
    @discardableResult
504
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
505
        if percent != nil {
506
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
507
        }
508

            
509
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
510
        if didSave {
511
            reloadChargedDevices()
512
        }
513
        return didSave
514
    }
515

            
516
    @discardableResult
517
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
518
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
519
        if didSave {
520
            reloadChargedDevices()
521
        }
522
        return didSave
523
    }
524

            
525
    @discardableResult
526
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
527
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
528
        if didSave {
529
            reloadChargedDevices()
530
        }
531
        return didSave
532
    }
533

            
534
    @discardableResult
535
    func deleteChargeSession(sessionID: UUID) -> Bool {
536
        let deletedSession = chargedDevices
537
            .flatMap(\.sessions)
538
            .first(where: { $0.id == sessionID })
539

            
540
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
541
        guard didDelete else {
542
            return false
543
        }
544

            
Bogdan Timofte authored a month ago
545
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
546
           let meterMACAddress = deletedSession?.meterMACAddress,
547
           let liveMeter = meter(for: meterMACAddress) {
548
            liveMeter.resetChargeRecord()
549
        }
550

            
551
        reloadChargedDevices()
552
        return true
553
    }
554

            
555
    @discardableResult
556
    func deleteChargedDevice(id: UUID) -> Bool {
557
        let deletedDevice = chargedDeviceSummary(id: id)
558
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
559
        guard didDelete else {
560
            return false
561
        }
562

            
563
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
564
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
565
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
566
           let liveMeter = meter(for: meterMACAddress) {
567
            liveMeter.resetChargeRecord()
568
        }
569

            
570
        reloadChargedDevices()
571
        return true
572
    }
573

            
574
    @discardableResult
575
    func createKnownMeter(
576
        macAddress: String,
577
        customName: String?,
578
        modelName: String,
579
        advertisedName: String?
580
    ) -> Bool {
581
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
582
        guard Self.isValidMACAddress(normalizedMAC) else {
583
            return false
584
        }
585

            
586
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
587
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
588
            setMeterName(customName, for: normalizedMAC)
589
        }
590
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
591
        return true
592
    }
593

            
594
    @discardableResult
595
    func deleteMeter(macAddress: String) -> Bool {
596
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
597
        guard Self.isValidMACAddress(normalizedMAC) else {
598
            return false
599
        }
600

            
601
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
602
            meter.disconnect()
603
        }
604
        meters = meters.filter { element in
605
            element.value.btSerial.macAddress.description != normalizedMAC
606
        }
607

            
608
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
609
        if didDelete {
610
            scheduleObjectWillChange()
611
        }
612
        return didDelete
613
    }
614

            
Bogdan Timofte authored 2 months ago
615
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored 2 months ago
616
        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
617
        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
618
        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
619

            
620
        return macAddresses.map { macAddress in
621
            let liveMeter = liveMetersByMAC[macAddress]
622
            let record = recordsByMAC[macAddress]
623

            
Bogdan Timofte authored 2 months ago
624
            return MeterSummary(
Bogdan Timofte authored 2 months ago
625
                macAddress: macAddress,
Bogdan Timofte authored 2 months ago
626
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
Bogdan Timofte authored 2 months ago
627
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
Bogdan Timofte authored 2 months ago
628
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
629
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
630
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
631
                meter: liveMeter
632
            )
633
        }
634
        .sorted { lhs, rhs in
Bogdan Timofte authored 2 months ago
635
            if lhs.meter != nil && rhs.meter == nil {
636
                return true
637
            }
638
            if lhs.meter == nil && rhs.meter != nil {
639
                return false
640
            }
Bogdan Timofte authored 2 months ago
641
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
642
            if byName != .orderedSame {
643
                return byName == .orderedAscending
644
            }
645
            return lhs.macAddress < rhs.macAddress
646
        }
647
    }
648

            
Bogdan Timofte authored 2 months ago
649
    private func scheduleObjectWillChange() {
650
        DispatchQueue.main.async { [weak self] in
651
            self?.objectWillChange.send()
652
        }
653
    }
Bogdan Timofte authored 2 months ago
654

            
Bogdan Timofte authored a month ago
655
    private func reloadChargedDevices() {
656
        chargedDevices = chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []
657
        chargeNotificationCoordinator.process(chargedDevices: deviceSummaries)
658
        for meter in meters.values {
659
            restoreChargeMonitoringStateIfNeeded(for: meter)
660
        }
661
    }
662

            
663
    private func meter(for meterMACAddress: String) -> Meter? {
664
        meters.values.first { meter in
665
            meter.btSerial.macAddress.description == meterMACAddress
666
        }
667
    }
668

            
Bogdan Timofte authored 2 months ago
669
    private func refreshMeterMetadata() {
670
        DispatchQueue.main.async { [weak self] in
671
            guard let self else { return }
672
            var didUpdateAnyMeter = false
673
            for meter in self.meters.values {
674
                let mac = meter.btSerial.macAddress.description
675
                let displayName = self.meterName(for: mac) ?? mac
676
                if meter.name != displayName {
677
                    meter.updateNameFromStore(displayName)
678
                    didUpdateAnyMeter = true
679
                }
680

            
681
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
682
                meter.reloadTemperatureUnitPreference()
683
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
684
                    didUpdateAnyMeter = true
685
                }
686
            }
687

            
688
            if didUpdateAnyMeter {
689
                self.scheduleObjectWillChange()
690
            }
691
        }
692
    }
Bogdan Timofte authored a month ago
693

            
694
    private func batteryCheckpointPlausibilityWarning(
695
        percent: Double,
696
        for session: ChargeSessionSummary
697
    ) -> BatteryCheckpointPlausibilityWarning? {
698
        guard percent.isFinite, percent >= 0, percent <= 100 else {
699
            return nil
700
        }
701

            
702
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
703
            if lhs.timestamp != rhs.timestamp {
704
                return lhs.timestamp < rhs.timestamp
705
            }
706
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
707
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
708
            }
709
            return lhs.id.uuidString < rhs.id.uuidString
710
        }
711

            
712
        if let lastCheckpoint = sortedCheckpoints.last,
713
           percent < lastCheckpoint.batteryPercent - 1.5 {
714
            return BatteryCheckpointPlausibilityWarning(
715
                title: "Checkpoint Goes Backwards",
716
                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."
717
            )
718
        }
719

            
720
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
721
              let prediction = chargedDevice.batteryLevelPrediction(for: session)
722
        else {
723
            return nil
724
        }
725

            
726
        let predictionGap = percent - prediction.predictedPercent
727
        guard abs(predictionGap) >= 4 else {
728
            return nil
729
        }
730

            
731
        let direction = predictionGap > 0 ? "above" : "below"
732
        let gapText = abs(predictionGap).format(decimalDigits: 0)
733
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
734

            
735
        if let lastCheckpoint = sortedCheckpoints.last {
736
            let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh
737
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
738
            return BatteryCheckpointPlausibilityWarning(
739
                title: "Checkpoint Looks Implausible",
740
                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."
741
            )
742
        }
743

            
744
        return BatteryCheckpointPlausibilityWarning(
745
            title: "Checkpoint Looks Implausible",
746
            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."
747
        )
748
    }
Bogdan Timofte authored 2 months ago
749
}
Bogdan Timofte authored 2 months ago
750

            
751
extension AppData.MeterSummary {
752
    var tint: Color {
753
        switch modelSummary {
754
        case "UM25C":
755
            return .blue
756
        case "UM34C":
757
            return .yellow
758
        case "TC66C":
759
            return Model.TC66C.color
760
        default:
761
            return .secondary
762
        }
763
    }
764
}
Bogdan Timofte authored 2 months ago
765

            
Bogdan Timofte authored a month ago
766
extension AppData {
Bogdan Timofte authored 2 months ago
767
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
768
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
769
            return liveName
770
        }
771
        if let customName = record?.customName {
772
            return customName
773
        }
774
        if let advertisedName = record?.advertisedName {
775
            return advertisedName
776
        }
777
        if let recordModel = record?.modelName {
778
            return recordModel
779
        }
780
        if let liveModel = liveMeter?.deviceModelSummary {
781
            return liveModel
782
        }
783
        return "Meter"
784
    }
Bogdan Timofte authored a month ago
785

            
786
    static func normalizedMACAddress(_ macAddress: String) -> String {
787
        macAddress
788
            .trimmingCharacters(in: .whitespacesAndNewlines)
789
            .uppercased()
790
    }
791

            
792
    static func isValidMACAddress(_ macAddress: String) -> Bool {
793
        macAddress.range(
794
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
795
            options: .regularExpression
796
        ) != nil
797
    }
798
}
799

            
800
private final class ChargeNotificationCoordinator {
801
    private struct Payload {
802
        let id: String
803
        let title: String
804
        let body: String
805
        let threadIdentifier: String
806
    }
807

            
808
    private let notificationCenter = UNUserNotificationCenter.current()
809
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
810
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
811
    private var inFlightEventIDs: Set<String> = []
812

            
813
    func ensureAuthorizationIfNeeded() {
814
        notificationCenter.getNotificationSettings { [weak self] settings in
815
            guard settings.authorizationStatus == .notDetermined else {
816
                return
817
            }
818

            
819
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
820
                if let error {
821
                    track("Notification authorization request failed: \(error.localizedDescription)")
822
                }
823
            }
824
        }
825
    }
826

            
827
    func process(chargedDevices: [ChargedDeviceSummary]) {
828
        let now = Date()
829
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
830
            payloads(for: chargedDevice, now: now)
831
        }
832

            
833
        for payload in pendingPayloads {
834
            scheduleIfNeeded(payload)
835
        }
836
    }
837

            
838
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
839
        chargedDevice.sessions.compactMap { session in
840
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
841
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
842
               let targetBatteryPercent = session.targetBatteryPercent {
843
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
844
                    ?? session.endBatteryPercent
845
                    ?? targetBatteryPercent
846

            
847
                return Payload(
848
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
849
                    title: "Battery target reached",
850
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
851
                    threadIdentifier: session.id.uuidString
852
                )
853
            }
854

            
855
            if session.requiresCompletionConfirmation,
856
               let requestedAt = session.completionConfirmationRequestedAt,
857
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
858
                let estimatedPercent = session.completionContradictionPercent
859
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
860
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
861
                let detail = estimatedPercent.map {
862
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
863
                } ?? ""
864

            
865
                return Payload(
866
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
867
                    title: "Confirm charge completion",
868
                    body: bodyPrefix + detail,
869
                    threadIdentifier: session.id.uuidString
870
                )
871
            }
872

            
873
            return nil
874
        }
875
    }
876

            
877
    private func scheduleIfNeeded(_ payload: Payload) {
878
        guard deliveredEventIDs().contains(payload.id) == false else {
879
            return
880
        }
881

            
882
        guard inFlightEventIDs.contains(payload.id) == false else {
883
            return
884
        }
885

            
886
        inFlightEventIDs.insert(payload.id)
887

            
888
        notificationCenter.getNotificationSettings { [weak self] settings in
889
            guard let self else { return }
890
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
891
                DispatchQueue.main.async {
892
                    self.inFlightEventIDs.remove(payload.id)
893
                }
894
                return
895
            }
896

            
897
            let content = UNMutableNotificationContent()
898
            content.title = payload.title
899
            content.body = payload.body
900
            content.sound = .default
901
            content.threadIdentifier = payload.threadIdentifier
902

            
903
            let request = UNNotificationRequest(
904
                identifier: payload.id,
905
                content: content,
906
                trigger: nil
907
            )
908

            
909
            self.notificationCenter.add(request) { error in
910
                DispatchQueue.main.async {
911
                    self.inFlightEventIDs.remove(payload.id)
912
                    if let error {
913
                        track("Failed scheduling local notification: \(error.localizedDescription)")
914
                        return
915
                    }
916
                    self.storeDeliveredEventID(payload.id)
917
                }
918
            }
919
        }
920
    }
921

            
922
    private func deliveredEventIDs() -> Set<String> {
923
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
924
        return Set(values)
925
    }
926

            
927
    private func storeDeliveredEventID(_ id: String) {
928
        var values = deliveredEventIDs()
929
        values.insert(id)
930
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
931
    }
Bogdan Timofte authored 2 months ago
932
}