Newer Older
976 lines | 34.657kb
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
        meter.resetMeterCountersForNewSession()
361

            
Bogdan Timofte authored a month ago
362
        guard let snapshot = meter.chargingMonitorSnapshot else {
363
            return false
364
        }
365

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

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

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

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

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

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

            
436
    @discardableResult
437
    func addBatteryCheckpoint(percent: Double, label: String?, for meter: Meter) -> Bool {
Bogdan Timofte authored a month ago
438
        observeChargeSnapshot(from: meter)
439

            
440
        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
441
        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
442
        let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
443

            
Bogdan Timofte authored a month ago
444
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
445
            percent: percent,
446
            label: label,
Bogdan Timofte authored a month ago
447
            for: meter.btSerial.macAddress.description,
448
            measuredEnergyWh: checkpointEnergyWh,
449
            measuredChargeAh: checkpointChargeAh
Bogdan Timofte authored a month ago
450
        ) ?? false
451

            
452
        if didSave {
453
            reloadChargedDevices()
454
        }
455

            
456
        return didSave
457
    }
458

            
459
    @discardableResult
460
    func addBatteryCheckpoint(percent: Double, label: String?, for sessionID: UUID) -> Bool {
461
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
462
            percent: percent,
463
            label: label,
464
            for: sessionID
465
        ) ?? false
466

            
467
        if didSave {
468
            reloadChargedDevices()
469
        }
470

            
471
        return didSave
472
    }
473

            
Bogdan Timofte authored a month ago
474
    func batteryCheckpointPlausibilityWarning(
475
        percent: Double,
476
        for sessionID: UUID
477
    ) -> BatteryCheckpointPlausibilityWarning? {
478
        guard let session = chargeSessionSummary(id: sessionID) else {
479
            return nil
480
        }
481
        return batteryCheckpointPlausibilityWarning(percent: percent, for: session)
482
    }
483

            
484
    @discardableResult
485
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
486
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
487
            id: checkpointID,
488
            from: sessionID
489
        ) ?? false
490

            
491
        if didDelete {
492
            reloadChargedDevices()
493
        }
494

            
495
        return didDelete
496
    }
497

            
Bogdan Timofte authored a month ago
498
    @discardableResult
499
    func flushChargeInsights() -> Bool {
500
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
501
        reloadChargedDevices()
502
        return didSave
503
    }
504

            
505
    @discardableResult
506
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
507
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
508
            return false
509
        }
510
        return setTargetBatteryPercent(percent, for: activeSession.id)
511
    }
512

            
513
    @discardableResult
514
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
515
        if percent != nil {
516
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
517
        }
518

            
519
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
520
        if didSave {
521
            reloadChargedDevices()
522
        }
523
        return didSave
524
    }
525

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

            
535
    @discardableResult
536
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
537
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
538
        if didSave {
539
            reloadChargedDevices()
540
        }
541
        return didSave
542
    }
543

            
544
    @discardableResult
545
    func deleteChargeSession(sessionID: UUID) -> Bool {
546
        let deletedSession = chargedDevices
547
            .flatMap(\.sessions)
548
            .first(where: { $0.id == sessionID })
549

            
550
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
551
        guard didDelete else {
552
            return false
553
        }
554

            
Bogdan Timofte authored a month ago
555
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
556
           let meterMACAddress = deletedSession?.meterMACAddress,
557
           let liveMeter = meter(for: meterMACAddress) {
558
            liveMeter.resetChargeRecord()
559
        }
560

            
561
        reloadChargedDevices()
562
        return true
563
    }
564

            
565
    @discardableResult
566
    func deleteChargedDevice(id: UUID) -> Bool {
567
        let deletedDevice = chargedDeviceSummary(id: id)
568
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
569
        guard didDelete else {
570
            return false
571
        }
572

            
573
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
574
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
575
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
576
           let liveMeter = meter(for: meterMACAddress) {
577
            liveMeter.resetChargeRecord()
578
        }
579

            
580
        reloadChargedDevices()
581
        return true
582
    }
583

            
584
    @discardableResult
585
    func createKnownMeter(
586
        macAddress: String,
587
        customName: String?,
588
        modelName: String,
589
        advertisedName: String?
590
    ) -> Bool {
591
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
592
        guard Self.isValidMACAddress(normalizedMAC) else {
593
            return false
594
        }
595

            
596
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
597
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
598
            setMeterName(customName, for: normalizedMAC)
599
        }
600
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
601
        return true
602
    }
603

            
604
    @discardableResult
605
    func deleteMeter(macAddress: String) -> Bool {
606
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
607
        guard Self.isValidMACAddress(normalizedMAC) else {
608
            return false
609
        }
610

            
611
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
612
            meter.disconnect()
613
        }
614
        meters = meters.filter { element in
615
            element.value.btSerial.macAddress.description != normalizedMAC
616
        }
617

            
618
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
619
        if didDelete {
620
            scheduleObjectWillChange()
621
        }
622
        return didDelete
623
    }
624

            
Bogdan Timofte authored 2 months ago
625
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored 2 months ago
626
        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
627
        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
628
        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
629

            
630
        return macAddresses.map { macAddress in
631
            let liveMeter = liveMetersByMAC[macAddress]
632
            let record = recordsByMAC[macAddress]
633

            
Bogdan Timofte authored 2 months ago
634
            return MeterSummary(
Bogdan Timofte authored 2 months ago
635
                macAddress: macAddress,
Bogdan Timofte authored 2 months ago
636
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
Bogdan Timofte authored 2 months ago
637
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
Bogdan Timofte authored 2 months ago
638
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
639
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
640
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
641
                meter: liveMeter
642
            )
643
        }
644
        .sorted { lhs, rhs in
Bogdan Timofte authored 2 months ago
645
            if lhs.meter != nil && rhs.meter == nil {
646
                return true
647
            }
648
            if lhs.meter == nil && rhs.meter != nil {
649
                return false
650
            }
Bogdan Timofte authored 2 months ago
651
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
652
            if byName != .orderedSame {
653
                return byName == .orderedAscending
654
            }
655
            return lhs.macAddress < rhs.macAddress
656
        }
657
    }
658

            
Bogdan Timofte authored 2 months ago
659
    private func scheduleObjectWillChange() {
660
        DispatchQueue.main.async { [weak self] in
661
            self?.objectWillChange.send()
662
        }
663
    }
Bogdan Timofte authored 2 months ago
664

            
Bogdan Timofte authored a month ago
665
    private func reloadChargedDevices() {
666
        chargedDevices = chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []
667
        chargeNotificationCoordinator.process(chargedDevices: deviceSummaries)
668
        for meter in meters.values {
669
            restoreChargeMonitoringStateIfNeeded(for: meter)
670
        }
671
    }
672

            
673
    private func meter(for meterMACAddress: String) -> Meter? {
674
        meters.values.first { meter in
675
            meter.btSerial.macAddress.description == meterMACAddress
676
        }
677
    }
678

            
Bogdan Timofte authored 2 months ago
679
    private func refreshMeterMetadata() {
680
        DispatchQueue.main.async { [weak self] in
681
            guard let self else { return }
682
            var didUpdateAnyMeter = false
683
            for meter in self.meters.values {
684
                let mac = meter.btSerial.macAddress.description
685
                let displayName = self.meterName(for: mac) ?? mac
686
                if meter.name != displayName {
687
                    meter.updateNameFromStore(displayName)
688
                    didUpdateAnyMeter = true
689
                }
690

            
691
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
692
                meter.reloadTemperatureUnitPreference()
693
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
694
                    didUpdateAnyMeter = true
695
                }
696
            }
697

            
698
            if didUpdateAnyMeter {
699
                self.scheduleObjectWillChange()
700
            }
701
        }
702
    }
Bogdan Timofte authored a month ago
703

            
704
    private func batteryCheckpointPlausibilityWarning(
705
        percent: Double,
706
        for session: ChargeSessionSummary
707
    ) -> BatteryCheckpointPlausibilityWarning? {
708
        guard percent.isFinite, percent >= 0, percent <= 100 else {
709
            return nil
710
        }
711

            
712
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
713
            if lhs.timestamp != rhs.timestamp {
714
                return lhs.timestamp < rhs.timestamp
715
            }
716
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
717
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
718
            }
719
            return lhs.id.uuidString < rhs.id.uuidString
720
        }
721

            
722
        if let lastCheckpoint = sortedCheckpoints.last,
723
           percent < lastCheckpoint.batteryPercent - 1.5 {
724
            return BatteryCheckpointPlausibilityWarning(
725
                title: "Checkpoint Goes Backwards",
726
                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."
727
            )
728
        }
729

            
730
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
731
              let prediction = chargedDevice.batteryLevelPrediction(for: session)
732
        else {
733
            return nil
734
        }
735

            
736
        let predictionGap = percent - prediction.predictedPercent
737
        guard abs(predictionGap) >= 4 else {
738
            return nil
739
        }
740

            
741
        let direction = predictionGap > 0 ? "above" : "below"
742
        let gapText = abs(predictionGap).format(decimalDigits: 0)
743
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
744

            
745
        if let lastCheckpoint = sortedCheckpoints.last {
746
            let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh
747
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
748
            return BatteryCheckpointPlausibilityWarning(
749
                title: "Checkpoint Looks Implausible",
750
                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."
751
            )
752
        }
753

            
754
        return BatteryCheckpointPlausibilityWarning(
755
            title: "Checkpoint Looks Implausible",
756
            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."
757
        )
758
    }
Bogdan Timofte authored a month ago
759

            
760
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
761
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
762
        guard session.status.isOpen else {
763
            return storedEnergyWh
764
        }
765

            
766
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
767
            return storedEnergyWh
768
        }
769

            
770
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
771
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
772
        }
773

            
774
        return storedEnergyWh
775
    }
776

            
777
    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
778
        let storedChargeAh = session.measuredChargeAh
779
        guard session.status.isOpen else {
780
            return storedChargeAh
781
        }
782

            
783
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
784
            return storedChargeAh
785
        }
786

            
787
        if let baselineChargeAh = session.meterChargeBaselineAh {
788
            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
789
        }
790

            
791
        return storedChargeAh
792
    }
Bogdan Timofte authored 2 months ago
793
}
Bogdan Timofte authored 2 months ago
794

            
795
extension AppData.MeterSummary {
796
    var tint: Color {
797
        switch modelSummary {
798
        case "UM25C":
799
            return .blue
800
        case "UM34C":
801
            return .yellow
802
        case "TC66C":
803
            return Model.TC66C.color
804
        default:
805
            return .secondary
806
        }
807
    }
808
}
Bogdan Timofte authored 2 months ago
809

            
Bogdan Timofte authored a month ago
810
extension AppData {
Bogdan Timofte authored 2 months ago
811
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
812
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
813
            return liveName
814
        }
815
        if let customName = record?.customName {
816
            return customName
817
        }
818
        if let advertisedName = record?.advertisedName {
819
            return advertisedName
820
        }
821
        if let recordModel = record?.modelName {
822
            return recordModel
823
        }
824
        if let liveModel = liveMeter?.deviceModelSummary {
825
            return liveModel
826
        }
827
        return "Meter"
828
    }
Bogdan Timofte authored a month ago
829

            
830
    static func normalizedMACAddress(_ macAddress: String) -> String {
831
        macAddress
832
            .trimmingCharacters(in: .whitespacesAndNewlines)
833
            .uppercased()
834
    }
835

            
836
    static func isValidMACAddress(_ macAddress: String) -> Bool {
837
        macAddress.range(
838
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
839
            options: .regularExpression
840
        ) != nil
841
    }
842
}
843

            
844
private final class ChargeNotificationCoordinator {
845
    private struct Payload {
846
        let id: String
847
        let title: String
848
        let body: String
849
        let threadIdentifier: String
850
    }
851

            
852
    private let notificationCenter = UNUserNotificationCenter.current()
853
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
854
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
855
    private var inFlightEventIDs: Set<String> = []
856

            
857
    func ensureAuthorizationIfNeeded() {
858
        notificationCenter.getNotificationSettings { [weak self] settings in
859
            guard settings.authorizationStatus == .notDetermined else {
860
                return
861
            }
862

            
863
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
864
                if let error {
865
                    track("Notification authorization request failed: \(error.localizedDescription)")
866
                }
867
            }
868
        }
869
    }
870

            
871
    func process(chargedDevices: [ChargedDeviceSummary]) {
872
        let now = Date()
873
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
874
            payloads(for: chargedDevice, now: now)
875
        }
876

            
877
        for payload in pendingPayloads {
878
            scheduleIfNeeded(payload)
879
        }
880
    }
881

            
882
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
883
        chargedDevice.sessions.compactMap { session in
884
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
885
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
886
               let targetBatteryPercent = session.targetBatteryPercent {
887
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
888
                    ?? session.endBatteryPercent
889
                    ?? targetBatteryPercent
890

            
891
                return Payload(
892
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
893
                    title: "Battery target reached",
894
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
895
                    threadIdentifier: session.id.uuidString
896
                )
897
            }
898

            
899
            if session.requiresCompletionConfirmation,
900
               let requestedAt = session.completionConfirmationRequestedAt,
901
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
902
                let estimatedPercent = session.completionContradictionPercent
903
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
904
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
905
                let detail = estimatedPercent.map {
906
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
907
                } ?? ""
908

            
909
                return Payload(
910
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
911
                    title: "Confirm charge completion",
912
                    body: bodyPrefix + detail,
913
                    threadIdentifier: session.id.uuidString
914
                )
915
            }
916

            
917
            return nil
918
        }
919
    }
920

            
921
    private func scheduleIfNeeded(_ payload: Payload) {
922
        guard deliveredEventIDs().contains(payload.id) == false else {
923
            return
924
        }
925

            
926
        guard inFlightEventIDs.contains(payload.id) == false else {
927
            return
928
        }
929

            
930
        inFlightEventIDs.insert(payload.id)
931

            
932
        notificationCenter.getNotificationSettings { [weak self] settings in
933
            guard let self else { return }
934
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
935
                DispatchQueue.main.async {
936
                    self.inFlightEventIDs.remove(payload.id)
937
                }
938
                return
939
            }
940

            
941
            let content = UNMutableNotificationContent()
942
            content.title = payload.title
943
            content.body = payload.body
944
            content.sound = .default
945
            content.threadIdentifier = payload.threadIdentifier
946

            
947
            let request = UNNotificationRequest(
948
                identifier: payload.id,
949
                content: content,
950
                trigger: nil
951
            )
952

            
953
            self.notificationCenter.add(request) { error in
954
                DispatchQueue.main.async {
955
                    self.inFlightEventIDs.remove(payload.id)
956
                    if let error {
957
                        track("Failed scheduling local notification: \(error.localizedDescription)")
958
                        return
959
                    }
960
                    self.storeDeliveredEventID(payload.id)
961
                }
962
            }
963
        }
964
    }
965

            
966
    private func deliveredEventIDs() -> Set<String> {
967
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
968
        return Set(values)
969
    }
970

            
971
    private func storeDeliveredEventID(_ id: String) {
972
        var values = deliveredEventIDs()
973
        values.insert(id)
974
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
975
    }
Bogdan Timofte authored 2 months ago
976
}