A meter can have at most one open charge session at any given time.
"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.
ChargeInsightsStore.startSession(...) checks for an existing open session on the same meterMACAddress before creating a new one:
guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
return
}
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.
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.
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.
statusRawValue itselfNSMergeByPropertyObjectTrumpMergePolicy 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.
ChargeInsightsStore.healDuplicateOpenSessions() is called from AppData.reloadChargedDevices() on every reload cycle, which is triggered by:
NSPersistentStoreRemoteChange notification)NSManagedObjectContextDidSave)active or paused).meterMACAddress.startedAt. This represents the user's most recent explicit intent — they chose to start a new session, which means the older one was forgotten.measuredEnergyWh wins (the session that observed more charging is likely more useful).abandoned, endedAt set to winner's startedAt.startedAt instead of updatedAt for winner selectionupdatedAt 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.
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:
ChargeSession entity (Core Data model v16) includes:
| Attribute | Type | Purpose |
|---|---|---|
wasConflictHealed |
Boolean, optional |
true on sessions closed by the healing mechanism. nil/false on normal sessions. |
This attribute is synced via CloudKit so all devices eventually see the flag, even if healing ran on only one of them.
Sessions with wasConflictHealed == true are visually distinguished in ChargedDeviceSessionsView:
The intent is to surface the event without alarming the user — the data is intact and the time range is correctly bounded.
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).
A proactive approach would require a "soft lock" written to CloudKit before starting a session, which introduces latency and complexity. Not implemented.
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.
chargedDeviceID across different metersThe 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.
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.
startedAt) should be re-evaluated — in that case the session with more observed data might be preferable.wasConflictHealed provides the signal to identify candidates.