Adds auto-healing for the scenario where a session is forgotten on one device and a new one is started on another while offline: after CloudKit sync both appear open. healDuplicateOpenSessions() resolves duplicates by keeping the most recently started session (explicit user intent), closing the loser with endedAt = winner.startedAt (no time overlap), and flagging it with wasConflictHealed = true. - ChargeInsightsStore: healDuplicateOpenSessions(), called on every reloadChargedDevices() alongside expireOverlongChargeSessionsIfNeeded() - CoreData model v16: Boolean wasConflictHealed attribute on ChargeSession - ChargeSessionSummary: wasConflictHealed field - ChargedDeviceSessionsView: orange icon + "Auto-closed (sync conflict)" label on healed sessions - Documentation: Charge Session Integrity and Conflict Healing.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -0,0 +1,128 @@ |
||
| 1 |
+# Charge Session Integrity and Conflict Healing |
|
| 2 |
+ |
|
| 3 |
+## Core Invariant |
|
| 4 |
+ |
|
| 5 |
+**A meter can have at most one open charge session at any given time.** |
|
| 6 |
+ |
|
| 7 |
+"Open" means `statusRawValue` is either `active` or `paused`. This invariant is used throughout the app to look up the active session for a meter without ambiguity. |
|
| 8 |
+ |
|
| 9 |
+--- |
|
| 10 |
+ |
|
| 11 |
+## How the Invariant Is Enforced at Creation |
|
| 12 |
+ |
|
| 13 |
+`ChargeInsightsStore.startSession(...)` checks for an existing open session on the same `meterMACAddress` before creating a new one: |
|
| 14 |
+ |
|
| 15 |
+```swift |
|
| 16 |
+guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
|
|
| 17 |
+ return |
|
| 18 |
+} |
|
| 19 |
+``` |
|
| 20 |
+ |
|
| 21 |
+This check is sufficient when all devices are online and share a consistent view of the CloudKit store. It is **not sufficient** when devices work offline independently. |
|
| 22 |
+ |
|
| 23 |
+--- |
|
| 24 |
+ |
|
| 25 |
+## Scenarios Where the Invariant Can Be Violated |
|
| 26 |
+ |
|
| 27 |
+### Scenario A — Forgotten session + new session on another device (primary concern) |
|
| 28 |
+ |
|
| 29 |
+1. The user starts a session on **Device A** and forgets about it (walks away, doesn't close the app). |
|
| 30 |
+2. The user picks up **Device B**, which has not yet synced from Device A. |
|
| 31 |
+3. Device B finds no open session for the meter (it doesn't know about Device A's session yet). |
|
| 32 |
+4. Device B creates a new session successfully. |
|
| 33 |
+5. When both devices come back online and CloudKit syncs, **both sessions are open** for the same meter. |
|
| 34 |
+ |
|
| 35 |
+This is the most realistic scenario. Current meters (UM25C, UM34C, TC66C) only support one BT connection at a time, so both sessions cannot be live simultaneously — but the forgotten session remains open in the database. |
|
| 36 |
+ |
|
| 37 |
+### Scenario B — Two devices simultaneously offline on meters that support multiple connections |
|
| 38 |
+ |
|
| 39 |
+If a future meter model supports multiple concurrent BT connections, two devices could legitimately start sessions for the same meter at the same time, both while offline. The healing mechanism covers this automatically, though the winner selection may be debatable in that case. |
|
| 40 |
+ |
|
| 41 |
+### Scenario C — CloudKit conflict on `statusRawValue` itself |
|
| 42 |
+ |
|
| 43 |
+`NSMergeByPropertyObjectTrumpMergePolicy` is used throughout: in-memory values win over CloudKit-synced values at the Core Data level. This means a `stopSession` write on Device A while Device B has also written to the same session record could theoretically produce inconsistent `statusRawValue` after sync. This is distinct from the duplicate-session scenario and is considered unlikely in practice, but worth noting. |
|
| 44 |
+ |
|
| 45 |
+--- |
|
| 46 |
+ |
|
| 47 |
+## Healing Mechanism |
|
| 48 |
+ |
|
| 49 |
+`ChargeInsightsStore.healDuplicateOpenSessions()` is called from `AppData.reloadChargedDevices()` on every reload cycle, which is triggered by: |
|
| 50 |
+ |
|
| 51 |
+- App startup (first device list load) |
|
| 52 |
+- Every remote CloudKit change (`NSPersistentStoreRemoteChange` notification) |
|
| 53 |
+- Every local Core Data write (via `NSManagedObjectContextDidSave`) |
|
| 54 |
+ |
|
| 55 |
+### Algorithm |
|
| 56 |
+ |
|
| 57 |
+1. Fetch all open sessions (status `active` or `paused`). |
|
| 58 |
+2. Group by `meterMACAddress`. |
|
| 59 |
+3. For any group with more than one session: |
|
| 60 |
+ - **Winner** = session with the latest `startedAt`. This represents the user's most recent explicit intent — they chose to start a new session, which means the older one was forgotten. |
|
| 61 |
+ - Tie-break: higher `measuredEnergyWh` wins (the session that observed more charging is likely more useful). |
|
| 62 |
+ - **Loser(s)**: closed with status `abandoned`, `endedAt` set to **winner's `startedAt`**. |
|
| 63 |
+4. Save, then refresh derived metrics for affected devices. |
|
| 64 |
+ |
|
| 65 |
+### Why `startedAt` instead of `updatedAt` for winner selection |
|
| 66 |
+ |
|
| 67 |
+`updatedAt` reflects the last write to the record, which could be CloudKit metadata or a background observation flush — not necessarily a user action. `startedAt` is set once at creation and directly represents the user's decision to begin a session. The session started later is the one the user *intended* to be active. |
|
| 68 |
+ |
|
| 69 |
+### Overlap prevention |
|
| 70 |
+ |
|
| 71 |
+The loser's `endedAt` is set to the winner's `startedAt` (not `Date()` / "now"). This ensures the two sessions are contiguous with no gap and no overlap, which is important for: |
|
| 72 |
+ |
|
| 73 |
+- Correct total energy accounting across a device's session history |
|
| 74 |
+- Clean chart rendering (no two sessions claiming the same time range) |
|
| 75 |
+- Capacity estimation (no double-counting of energy delivered in the same window) |
|
| 76 |
+ |
|
| 77 |
+--- |
|
| 78 |
+ |
|
| 79 |
+## Data Model |
|
| 80 |
+ |
|
| 81 |
+`ChargeSession` entity (Core Data model v16) includes: |
|
| 82 |
+ |
|
| 83 |
+| Attribute | Type | Purpose | |
|
| 84 |
+|---|---|---| |
|
| 85 |
+| `wasConflictHealed` | `Boolean, optional` | `true` on sessions closed by the healing mechanism. `nil`/`false` on normal sessions. | |
|
| 86 |
+ |
|
| 87 |
+This attribute is synced via CloudKit so all devices eventually see the flag, even if healing ran on only one of them. |
|
| 88 |
+ |
|
| 89 |
+--- |
|
| 90 |
+ |
|
| 91 |
+## UI Indicators |
|
| 92 |
+ |
|
| 93 |
+Sessions with `wasConflictHealed == true` are visually distinguished in `ChargedDeviceSessionsView`: |
|
| 94 |
+ |
|
| 95 |
+- An **orange icon** (⟳) appears next to the status badge in the session card header, with a tooltip: *"This session was automatically closed because a newer session was started on another device while offline."* |
|
| 96 |
+- The secondary info line includes **"Auto-closed (sync conflict)"** alongside other metadata (transport mode, trim, source mode). |
|
| 97 |
+ |
|
| 98 |
+The intent is to surface the event without alarming the user — the data is intact and the time range is correctly bounded. |
|
| 99 |
+ |
|
| 100 |
+--- |
|
| 101 |
+ |
|
| 102 |
+## What Is Not Covered |
|
| 103 |
+ |
|
| 104 |
+### Detection at session-start time |
|
| 105 |
+ |
|
| 106 |
+When Device B starts a session while offline, it cannot know that Device A has an open session. There is no mechanism to warn the user before the conflict occurs. Healing is entirely reactive (post-sync). |
|
| 107 |
+ |
|
| 108 |
+A proactive approach would require a "soft lock" written to CloudKit before starting a session, which introduces latency and complexity. Not implemented. |
|
| 109 |
+ |
|
| 110 |
+### Manual conflict resolution |
|
| 111 |
+ |
|
| 112 |
+Currently the loser is always automatically abandoned. There is no UI for the user to inspect the conflict and choose which session to keep, merge, or adjust. For the current meter lineup (single-connection BT), the automatic resolution is almost always correct. |
|
| 113 |
+ |
|
| 114 |
+### Overlapping sessions on the same `chargedDeviceID` across different meters |
|
| 115 |
+ |
|
| 116 |
+The invariant is enforced per `meterMACAddress`, not per `chargedDeviceID`. If the same physical device is charged on two different meters simultaneously (unlikely but possible with future hardware), both sessions would have different MAC addresses and the invariant would not catch them. The healing mechanism would not fire because there is no duplicate per MAC. |
|
| 117 |
+ |
|
| 118 |
+### Paused session + new session conflict |
|
| 119 |
+ |
|
| 120 |
+A paused session is still "open" and is treated the same as an active session for healing purposes. If healing runs on a paused + active pair, the paused session (earlier `startedAt`) becomes the loser. This is correct: the user started a new session, so the paused one was effectively abandoned. |
|
| 121 |
+ |
|
| 122 |
+--- |
|
| 123 |
+ |
|
| 124 |
+## Future Considerations |
|
| 125 |
+ |
|
| 126 |
+- If future meters support multiple concurrent connections, the winner-selection strategy (latest `startedAt`) should be re-evaluated — in that case the session with more observed data might be preferable. |
|
| 127 |
+- If session merging becomes desirable (combine energy from both conflicting sessions into one record), `wasConflictHealed` provides the signal to identify candidates. |
|
| 128 |
+- A "conflict history" section in the device detail or a one-time notification could be added to inform users when healing has run, especially if conflicts become more frequent with new hardware. |
|
@@ -12,6 +12,8 @@ It is intended to keep the repository root focused on the app itself while prese |
||
| 12 | 12 |
App-level platform choices that affect implementation. |
| 13 | 13 |
- `Charging While Off.md` |
| 14 | 14 |
Definition + measurement implications for capacity estimation. |
| 15 |
+- `Charge Session Integrity and Conflict Healing.md` |
|
| 16 |
+ The one-active-session-per-meter invariant, how it can be violated in offline sync scenarios, the healing mechanism, and what is not yet covered. |
|
| 15 | 17 |
- `Project Structure and Naming.md` |
| 16 | 18 |
Naming and file-organization rules for views, features, components, and subviews. |
| 17 | 19 |
- `External Contributions.md` |
@@ -225,6 +225,7 @@ |
||
| 225 | 225 |
D28F11443C8E4A7A00A10054 /* MeterDataGroupsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterDataGroupsTabView.swift; sourceTree = "<group>"; };
|
| 226 | 226 |
E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 14.xcdatamodel"; sourceTree = "<group>"; };
|
| 227 | 227 |
F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 15.xcdatamodel"; sourceTree = "<group>"; };
|
| 228 |
+ F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 16.xcdatamodel"; sourceTree = "<group>"; };
|
|
| 228 | 229 |
F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargingWindowDetector.swift; sourceTree = "<group>"; };
|
| 229 | 230 |
/* End PBXFileReference section */ |
| 230 | 231 |
|
@@ -1188,8 +1189,9 @@ |
||
| 1188 | 1189 |
C1F000033C90000100A10003 /* USB_Meter 12.xcdatamodel */, |
| 1189 | 1190 |
E23F11146F5D4C95B6487C94 /* USB_Meter 14.xcdatamodel */, |
| 1190 | 1191 |
F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */, |
| 1192 |
+ F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */, |
|
| 1191 | 1193 |
); |
| 1192 |
- currentVersion = F10000016F5D4C95B6487F15 /* USB_Meter 15.xcdatamodel */; |
|
| 1194 |
+ currentVersion = F10000026F5D4C95B6487F17 /* USB_Meter 16.xcdatamodel */; |
|
| 1193 | 1195 |
path = CKModel.xcdatamodeld; |
| 1194 | 1196 |
sourceTree = "<group>"; |
| 1195 | 1197 |
versionGroupType = wrapper.xcdatamodel; |
@@ -1021,6 +1021,11 @@ final class AppData : ObservableObject {
|
||
| 1021 | 1021 |
.first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
|
| 1022 | 1022 |
} |
| 1023 | 1023 |
|
| 1024 |
+ @discardableResult |
|
| 1025 |
+ private func healDuplicateOpenSessions() -> Bool {
|
|
| 1026 |
+ chargeInsightsStore?.healDuplicateOpenSessions() ?? false |
|
| 1027 |
+ } |
|
| 1028 |
+ |
|
| 1024 | 1029 |
@discardableResult |
| 1025 | 1030 |
private func expireOverlongChargeSessionsIfNeeded(referenceDate: Date = Date()) -> Bool {
|
| 1026 | 1031 |
chargeInsightsStore?.completeExpiredOpenSessions(referenceDate: referenceDate) ?? false |
@@ -1037,6 +1042,7 @@ final class AppData : ObservableObject {
|
||
| 1037 | 1042 |
pendingChargedDevicesReloadWorkItem?.cancel() |
| 1038 | 1043 |
pendingChargedDevicesReloadWorkItem = nil |
| 1039 | 1044 |
|
| 1045 |
+ _ = healDuplicateOpenSessions() |
|
| 1040 | 1046 |
_ = expireOverlongChargeSessionsIfNeeded() |
| 1041 | 1047 |
|
| 1042 | 1048 |
guard chargedDevicesReloadInFlight == false else {
|
@@ -3,6 +3,6 @@ |
||
| 3 | 3 |
<plist version="1.0"> |
| 4 | 4 |
<dict> |
| 5 | 5 |
<key>_XCCurrentVersionName</key> |
| 6 |
- <string>USB_Meter 15.xcdatamodel</string> |
|
| 6 |
+ <string>USB_Meter 16.xcdatamodel</string> |
|
| 7 | 7 |
</dict> |
| 8 | 8 |
</plist> |
@@ -0,0 +1,127 @@ |
||
| 1 |
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
|
| 2 |
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES"> |
|
| 3 |
+ <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class"> |
|
| 4 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 5 |
+ <attribute name="name" optional="YES" attributeType="String"/> |
|
| 6 |
+ <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/> |
|
| 7 |
+ <attribute name="deviceTemplateID" optional="YES" attributeType="String"/> |
|
| 8 |
+ <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 9 |
+ <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/> |
|
| 10 |
+ <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
|
| 11 |
+ <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 12 |
+ <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 13 |
+ <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/> |
|
| 14 |
+ <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/> |
|
| 15 |
+ <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/> |
|
| 16 |
+ <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 17 |
+ <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 18 |
+ <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 19 |
+ <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 20 |
+ <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 21 |
+ <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 22 |
+ <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 23 |
+ <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 24 |
+ <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 25 |
+ <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/> |
|
| 26 |
+ <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 27 |
+ <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 28 |
+ <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 29 |
+ <attribute name="qrIdentifier" optional="YES" attributeType="String"/> |
|
| 30 |
+ <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/> |
|
| 31 |
+ <attribute name="notes" optional="YES" attributeType="String"/> |
|
| 32 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 33 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 34 |
+ </entity> |
|
| 35 |
+ <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class"> |
|
| 36 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 37 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 38 |
+ <attribute name="chargerID" optional="YES" attributeType="String"/> |
|
| 39 |
+ <attribute name="meterMACAddress" optional="YES" attributeType="String"/> |
|
| 40 |
+ <attribute name="meterName" optional="YES" attributeType="String"/> |
|
| 41 |
+ <attribute name="meterModel" optional="YES" attributeType="String"/> |
|
| 42 |
+ <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 43 |
+ <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 44 |
+ <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 45 |
+ <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 46 |
+ <attribute name="statusRawValue" optional="YES" attributeType="String"/> |
|
| 47 |
+ <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/> |
|
| 48 |
+ <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/> |
|
| 49 |
+ <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/> |
|
| 50 |
+ <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> |
|
| 51 |
+ <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 52 |
+ <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 53 |
+ <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 54 |
+ <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 55 |
+ <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 56 |
+ <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 57 |
+ <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 58 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 59 |
+ <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 60 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 61 |
+ <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 62 |
+ <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 63 |
+ <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 64 |
+ <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 65 |
+ <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 66 |
+ <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 67 |
+ <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 68 |
+ <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 69 |
+ <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 70 |
+ <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 71 |
+ <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 72 |
+ <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 73 |
+ <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 74 |
+ <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 75 |
+ <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 76 |
+ <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 77 |
+ <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 78 |
+ <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 79 |
+ <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 80 |
+ <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 81 |
+ <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 82 |
+ <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 83 |
+ <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> |
|
| 84 |
+ <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 85 |
+ <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 86 |
+ <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 87 |
+ <attribute name="trimStart" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 88 |
+ <attribute name="trimEnd" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 89 |
+ <attribute name="wasConflictHealed" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> |
|
| 90 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 91 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 92 |
+ </entity> |
|
| 93 |
+ <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class"> |
|
| 94 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 95 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 96 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 97 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 98 |
+ <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 99 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 100 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 101 |
+ <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 102 |
+ <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 103 |
+ <attribute name="label" optional="YES" attributeType="String"/> |
|
| 104 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 105 |
+ </entity> |
|
| 106 |
+ <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="NO" codeGenerationType="class"> |
|
| 107 |
+ <attribute name="id" optional="YES" attributeType="String"/> |
|
| 108 |
+ <attribute name="sessionID" optional="YES" attributeType="String"/> |
|
| 109 |
+ <attribute name="chargedDeviceID" optional="YES" attributeType="String"/> |
|
| 110 |
+ <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/> |
|
| 111 |
+ <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 112 |
+ <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 113 |
+ <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/> |
|
| 114 |
+ <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 115 |
+ <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 116 |
+ <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
|
| 117 |
+ <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/> |
|
| 118 |
+ <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 119 |
+ <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
|
| 120 |
+ </entity> |
|
| 121 |
+ <elements> |
|
| 122 |
+ <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="433"/> |
|
| 123 |
+ <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="883"/> |
|
| 124 |
+ <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/> |
|
| 125 |
+ <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/> |
|
| 126 |
+ </elements> |
|
| 127 |
+</model> |
|
@@ -663,6 +663,7 @@ struct ChargeSessionSummary: Identifiable, Hashable {
|
||
| 663 | 663 |
let selectedDataGroup: UInt8? |
| 664 | 664 |
let trimStart: Date? |
| 665 | 665 |
let trimEnd: Date? |
| 666 |
+ let wasConflictHealed: Bool |
|
| 666 | 667 |
let checkpoints: [ChargeCheckpointSummary] |
| 667 | 668 |
let aggregatedSamples: [ChargeSessionSampleSummary] |
| 668 | 669 |
|
@@ -119,6 +119,67 @@ final class ChargeInsightsStore {
|
||
| 119 | 119 |
return didSave |
| 120 | 120 |
} |
| 121 | 121 |
|
| 122 |
+ // Heals the invariant "at most one open session per meter MAC". |
|
| 123 |
+ // Called after every remote CloudKit sync import to resolve sessions that were started |
|
| 124 |
+ // independently on different devices while offline. |
|
| 125 |
+ // |
|
| 126 |
+ // Scenario: session A started on Device 1 and forgotten; user starts session B on Device 2 |
|
| 127 |
+ // while offline. After sync both appear open for the same meter. |
|
| 128 |
+ // |
|
| 129 |
+ // Winner = session with the latest startedAt (represents the user's intentional new session). |
|
| 130 |
+ // Loser endedAt is set to winner's startedAt so there is no time overlap. |
|
| 131 |
+ @discardableResult |
|
| 132 |
+ func healDuplicateOpenSessions() -> Bool {
|
|
| 133 |
+ var didSave = false |
|
| 134 |
+ |
|
| 135 |
+ context.performAndWait {
|
|
| 136 |
+ let openSessions = fetchOpenSessionObjects() |
|
| 137 |
+ |
|
| 138 |
+ var sessionsByMAC: [String: [NSManagedObject]] = [:] |
|
| 139 |
+ for session in openSessions {
|
|
| 140 |
+ guard let mac = stringValue(session, key: "meterMACAddress") else { continue }
|
|
| 141 |
+ sessionsByMAC[mac, default: []].append(session) |
|
| 142 |
+ } |
|
| 143 |
+ |
|
| 144 |
+ let duplicatedMACs = sessionsByMAC.filter { $0.value.count > 1 }
|
|
| 145 |
+ guard !duplicatedMACs.isEmpty else { return }
|
|
| 146 |
+ |
|
| 147 |
+ var chargedDeviceIDsToRefresh = Set<String>() |
|
| 148 |
+ |
|
| 149 |
+ for (_, sessions) in duplicatedMACs {
|
|
| 150 |
+ // Winner = most recently started (explicit user intent); tie-break by measuredEnergyWh |
|
| 151 |
+ let winner = sessions.max { a, b in
|
|
| 152 |
+ let aDate = (a.value(forKey: "startedAt") as? Date) ?? .distantPast |
|
| 153 |
+ let bDate = (b.value(forKey: "startedAt") as? Date) ?? .distantPast |
|
| 154 |
+ if aDate != bDate { return aDate < bDate }
|
|
| 155 |
+ let aEnergy = (a.value(forKey: "measuredEnergyWh") as? Double) ?? 0 |
|
| 156 |
+ let bEnergy = (b.value(forKey: "measuredEnergyWh") as? Double) ?? 0 |
|
| 157 |
+ return aEnergy < bEnergy |
|
| 158 |
+ } |
|
| 159 |
+ let winnerStartedAt = (winner?.value(forKey: "startedAt") as? Date) ?? Date() |
|
| 160 |
+ |
|
| 161 |
+ for loser in sessions where loser !== winner {
|
|
| 162 |
+ // End the loser exactly when the winner began — no overlap. |
|
| 163 |
+ finishSession(loser, observedAt: winnerStartedAt, finalBatteryPercent: nil, status: .abandoned) |
|
| 164 |
+ loser.setValue(true, forKey: "wasConflictHealed") |
|
| 165 |
+ if let chargedDeviceID = stringValue(loser, key: "chargedDeviceID") {
|
|
| 166 |
+ chargedDeviceIDsToRefresh.insert(chargedDeviceID) |
|
| 167 |
+ } |
|
| 168 |
+ track("ChargeInsightsStore: healed duplicate open session \(stringValue(loser, key: "id") ?? "?") for meter \(stringValue(loser, key: "meterMACAddress") ?? "?")")
|
|
| 169 |
+ } |
|
| 170 |
+ } |
|
| 171 |
+ |
|
| 172 |
+ guard saveContext() else { return }
|
|
| 173 |
+ |
|
| 174 |
+ for chargedDeviceID in chargedDeviceIDsToRefresh {
|
|
| 175 |
+ refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) |
|
| 176 |
+ } |
|
| 177 |
+ didSave = saveContext() |
|
| 178 |
+ } |
|
| 179 |
+ |
|
| 180 |
+ return didSave |
|
| 181 |
+ } |
|
| 182 |
+ |
|
| 122 | 183 |
@discardableResult |
| 123 | 184 |
func createDevice( |
| 124 | 185 |
name: String, |
@@ -2488,6 +2549,7 @@ final class ChargeInsightsStore {
|
||
| 2488 | 2549 |
selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init), |
| 2489 | 2550 |
trimStart: dateValue(object, key: "trimStart"), |
| 2490 | 2551 |
trimEnd: dateValue(object, key: "trimEnd"), |
| 2552 |
+ wasConflictHealed: boolValue(object, key: "wasConflictHealed"), |
|
| 2491 | 2553 |
checkpoints: checkpointSummaries, |
| 2492 | 2554 |
aggregatedSamples: sampleSummaries |
| 2493 | 2555 |
) |
@@ -140,6 +140,13 @@ struct ChargedDeviceSessionsView: View {
|
||
| 140 | 140 |
.padding(.vertical, 4) |
| 141 | 141 |
.background(Capsule().fill(sessionTint.opacity(0.16))) |
| 142 | 142 |
|
| 143 |
+ if session.wasConflictHealed {
|
|
| 144 |
+ Image(systemName: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90") |
|
| 145 |
+ .font(.caption2.weight(.semibold)) |
|
| 146 |
+ .foregroundColor(.orange) |
|
| 147 |
+ .help("This session was automatically closed because a newer session was started on another device while offline.")
|
|
| 148 |
+ } |
|
| 149 |
+ |
|
| 143 | 150 |
Spacer() |
| 144 | 151 |
|
| 145 | 152 |
Image(systemName: "chevron.right") |
@@ -363,6 +370,9 @@ struct ChargedDeviceSessionsView: View {
|
||
| 363 | 370 |
if session.isTrimmed {
|
| 364 | 371 |
components.append("Trimmed")
|
| 365 | 372 |
} |
| 373 |
+ if session.wasConflictHealed {
|
|
| 374 |
+ components.append("Auto-closed (sync conflict)")
|
|
| 375 |
+ } |
|
| 366 | 376 |
components.append(session.sourceMode.title) |
| 367 | 377 |
|
| 368 | 378 |
return components.joined(separator: " · ") |