Showing 3 changed files with 140 additions and 18 deletions
+131 -16
USB Meter/Model/AppData.swift
@@ -44,12 +44,16 @@ final class AppData : ObservableObject {
44 44
     private var chargerStandbyPowerStoreObserver: AnyCancellable?
45 45
     private var pendingChargedDevicesReloadWorkItem: DispatchWorkItem?
46 46
     private var chargeInsightsReadStore: ChargeInsightsStore?
47
+    private var pendingChargeObservationSnapshots: [String: ChargingMonitorSnapshot] = [:]
48
+    private var pendingChargeObservationWorkItems: [String: DispatchWorkItem] = [:]
47 49
     private let chargedDevicesReloadQueue = DispatchQueue(
48 50
         label: "ro.xdev.usb-meter.charged-devices-reload",
49 51
         qos: .userInitiated
50 52
     )
51 53
     private var chargedDevicesReloadInFlight = false
52 54
     private var chargedDevicesReloadPending = false
55
+    private let chargeObservationPersistInterval: TimeInterval = 30
56
+    private let meterPresencePersistInterval: TimeInterval = 15
53 57
     private let meterStore = MeterNameStore.shared
54 58
     private var chargeInsightsStore: ChargeInsightsStore?
55 59
     private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
@@ -103,23 +107,44 @@ final class AppData : ObservableObject {
103 107
 
104 108
         context.automaticallyMergesChangesFromParent = true
105 109
         context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
106
-        chargeInsightsStore = ChargeInsightsStore(context: context)
107
-
108 110
         if let coordinator = context.persistentStoreCoordinator {
111
+            let writeContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
112
+            writeContext.persistentStoreCoordinator = coordinator
113
+            writeContext.automaticallyMergesChangesFromParent = false
114
+            writeContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
115
+            chargeInsightsStore = ChargeInsightsStore(context: writeContext)
116
+
109 117
             let readContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
110 118
             readContext.persistentStoreCoordinator = coordinator
111 119
             readContext.automaticallyMergesChangesFromParent = true
112 120
             readContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
113 121
             chargeInsightsReadStore = ChargeInsightsStore(context: readContext)
114
-        }
115 122
 
116
-        chargeInsightsStoreObserver = NotificationCenter.default.publisher(
117
-            for: .NSManagedObjectContextObjectsDidChange,
118
-            object: context
119
-        )
120
-        .receive(on: DispatchQueue.main)
121
-        .sink { [weak self] _ in
122
-            self?.scheduleChargedDevicesReload()
123
+            chargeInsightsStoreObserver = NotificationCenter.default.publisher(
124
+                for: .NSManagedObjectContextDidSave,
125
+                object: writeContext
126
+            )
127
+            .sink { [weak self, weak context] notification in
128
+                guard let self, let context else { return }
129
+                context.perform {
130
+                    context.mergeChanges(fromContextDidSave: notification)
131
+                    DispatchQueue.main.async {
132
+                        self.scheduleChargedDevicesReload()
133
+                    }
134
+                }
135
+            }
136
+        } else {
137
+            chargeInsightsStore = ChargeInsightsStore(context: context)
138
+            chargeInsightsReadStore = ChargeInsightsStore(context: context)
139
+
140
+            chargeInsightsStoreObserver = NotificationCenter.default.publisher(
141
+                for: .NSManagedObjectContextDidSave,
142
+                object: context
143
+            )
144
+            .receive(on: DispatchQueue.main)
145
+            .sink { [weak self] _ in
146
+                self?.scheduleChargedDevicesReload()
147
+            }
123 148
         }
124 149
 
125 150
         chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
@@ -157,6 +182,10 @@ final class AppData : ObservableObject {
157 182
     }
158 183
 
159 184
     func noteMeterSeen(at date: Date, macAddress: String) {
185
+        if let persistedLastSeen = meterStore.lastSeen(for: macAddress),
186
+           date.timeIntervalSince(persistedLastSeen) < meterPresencePersistInterval {
187
+            return
188
+        }
160 189
         meterStore.noteLastSeen(date, for: macAddress)
161 190
     }
162 191
 
@@ -502,6 +531,13 @@ final class AppData : ObservableObject {
502 531
     @discardableResult
503 532
     func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
504 533
         let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
534
+
535
+        if let meter {
536
+            _ = persistChargeSnapshot(from: meter, observedAt: observedAt)
537
+        } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
538
+            _ = flushPendingChargeObservation(for: meterMACAddress)
539
+        }
540
+
505 541
         let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
506 542
         if didSave {
507 543
             reloadChargedDevices()
@@ -521,6 +557,10 @@ final class AppData : ObservableObject {
521 557
 
522 558
     @discardableResult
523 559
     func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil) -> Bool {
560
+        if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
561
+            _ = flushPendingChargeObservation(for: meterMACAddress)
562
+        }
563
+
524 564
         let didSave = chargeInsightsStore?.stopSession(
525 565
             id: sessionID,
526 566
             finalBatteryPercent: finalBatteryPercent
@@ -534,14 +574,12 @@ final class AppData : ObservableObject {
534 574
             return
535 575
         }
536 576
 
537
-        if chargeInsightsStore?.observe(snapshot: snapshot) == true {
538
-            reloadChargedDevices()
539
-        }
577
+        stageChargeObservation(snapshot)
540 578
     }
541 579
 
542 580
     @discardableResult
543 581
     func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
544
-        observeChargeSnapshot(from: meter)
582
+        _ = persistChargeSnapshot(from: meter)
545 583
 
546 584
         let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
547 585
         let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
@@ -662,9 +700,12 @@ final class AppData : ObservableObject {
662 700
 
663 701
     @discardableResult
664 702
     func flushChargeInsights() -> Bool {
703
+        let didFlushObservations = flushAllPendingChargeObservations()
665 704
         let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
666
-        reloadChargedDevices()
667
-        return didSave
705
+        if didFlushObservations || didSave {
706
+            reloadChargedDevices()
707
+        }
708
+        return didFlushObservations || didSave
668 709
     }
669 710
 
670 711
     @discardableResult
@@ -845,6 +886,80 @@ final class AppData : ObservableObject {
845 886
         DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
846 887
     }
847 888
 
889
+    private func stageChargeObservation(_ snapshot: ChargingMonitorSnapshot, scheduleFlush: Bool = true) {
890
+        let normalizedMAC = Self.normalizedMACAddress(snapshot.meterMACAddress)
891
+        guard !normalizedMAC.isEmpty else {
892
+            return
893
+        }
894
+
895
+        pendingChargeObservationSnapshots[normalizedMAC] = snapshot
896
+
897
+        guard scheduleFlush else {
898
+            return
899
+        }
900
+
901
+        guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
902
+            return
903
+        }
904
+
905
+        let workItem = DispatchWorkItem { [weak self] in
906
+            guard let self else { return }
907
+            self.pendingChargeObservationWorkItems[normalizedMAC] = nil
908
+            guard let snapshot = self.pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
909
+                return
910
+            }
911
+            // CoreData write on background — DidSave observer handles the reload
912
+            let store = self.chargeInsightsStore
913
+            DispatchQueue.global(qos: .utility).async {
914
+                store?.observe(snapshot: snapshot)
915
+            }
916
+        }
917
+        pendingChargeObservationWorkItems[normalizedMAC] = workItem
918
+        DispatchQueue.main.asyncAfter(deadline: .now() + chargeObservationPersistInterval, execute: workItem)
919
+    }
920
+
921
+    @discardableResult
922
+    private func persistChargeSnapshot(from meter: Meter, observedAt: Date = Date()) -> Bool {
923
+        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
924
+            return false
925
+        }
926
+
927
+        stageChargeObservation(snapshot, scheduleFlush: false)
928
+        return flushPendingChargeObservation(for: snapshot.meterMACAddress)
929
+    }
930
+
931
+    @discardableResult
932
+    private func flushPendingChargeObservation(for meterMACAddress: String) -> Bool {
933
+        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
934
+        guard !normalizedMAC.isEmpty else {
935
+            return false
936
+        }
937
+
938
+        pendingChargeObservationWorkItems[normalizedMAC]?.cancel()
939
+        pendingChargeObservationWorkItems[normalizedMAC] = nil
940
+
941
+        guard let snapshot = pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
942
+            return false
943
+        }
944
+
945
+        let didSave = chargeInsightsStore?.observe(snapshot: snapshot) ?? false
946
+        return didSave
947
+    }
948
+
949
+    @discardableResult
950
+    private func flushAllPendingChargeObservations() -> Bool {
951
+        let pendingMeterMACAddresses = Array(pendingChargeObservationSnapshots.keys)
952
+        var didSave = false
953
+
954
+        for meterMACAddress in pendingMeterMACAddresses {
955
+            if flushPendingChargeObservation(for: meterMACAddress) {
956
+                didSave = true
957
+            }
958
+        }
959
+
960
+        return didSave
961
+    }
962
+
848 963
     private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
849 964
         let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
850 965
         guard !normalizedMAC.isEmpty else {
+1 -1
USB Meter/Model/ChargeInsightsStore.swift
@@ -35,7 +35,7 @@ final class ChargeInsightsStore {
35 35
 
36 36
     private let context: NSManagedObjectContext
37 37
     private let stopDetectionHoldDuration: TimeInterval = 20
38
-    private let maximumLiveIntegrationGap: TimeInterval = 20
38
+    private let maximumLiveIntegrationGap: TimeInterval = 90
39 39
     private let activeSessionSaveInterval: TimeInterval = 60
40 40
     private let aggregatedSampleSaveInterval: TimeInterval = 30
41 41
     private let counterDecreaseTolerance = 0.002
+8 -1
USB Meter/Model/Meter.swift
@@ -430,6 +430,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
430 430
     
431 431
     var measurements = Measurements()
432 432
 
433
+    private let minimumLivePollingInterval: TimeInterval = 0.4
433 434
     private var commandQueue: [Data] = []
434 435
     private var dataDumpRequestTimestamp = Date()
435 436
     private var pendingDataDumpWorkItem: DispatchWorkItem?
@@ -671,6 +672,12 @@ class Meter : NSObject, ObservableObject, Identifiable {
671 672
         DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
672 673
     }
673 674
 
675
+    private func scheduleNextLiveDataDumpRequest() {
676
+        let elapsedSinceLastRequest = Date().timeIntervalSince(dataDumpRequestTimestamp)
677
+        let delay = max(minimumLivePollingInterval - elapsedSinceLastRequest, 0)
678
+        scheduleDataDumpRequest(after: delay, reason: "continuous live polling")
679
+    }
680
+
674 681
     private func noteInitiatedVolatileMemoryResetIfNeeded(for groupID: UInt8) {
675 682
         guard groupID == 0 else { return }
676 683
         pendingVolatileMemoryResetIgnoreCount += 1
@@ -830,7 +837,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
830 837
         } else if liveDataChanged {
831 838
             objectWillChange.send()
832 839
         }
833
-        dataDumpRequest()
840
+        scheduleNextLiveDataDumpRequest()
834 841
     }
835 842
 
836 843
     private func apply(umSnapshot snapshot: UMSnapshot) {