- Meter.swift: enforce minimum 0.4s between live polling requests instead of immediate recursion after each BLE response - AppData.swift: coalesce BLE snapshots in memory; persist to CoreData every 30s via scheduled flush with explicit flush at pause/stop/checkpoint/terminate; throttle lastSeen writes to every 15s; switch reload trigger from ObjectsDidChange to DidSave to avoid UI invalidation on every intermediate mutation - AppData.swift: scheduled flush dispatches CoreData write to background thread — DidSave observer handles the reload, removing the redundant double-reload per flush cycle - ChargeInsightsStore.swift: widen maximumLiveIntegrationGap to 90s to match the 30s persist window Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -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 {
|
@@ -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 |
@@ -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) {
|