Repository navigation
SportMode - necessary LoopKit changes to allow a watch controller - #626
Conversation
…eeds Device hand-off (DeviceManager.swift, PumpManager.swift). None of it names a phone, a watch, a lender or a borrower; a manager that does not conform changes in no way. - SharedDeviceConfiguration: what another controller needs to use a device. An opaque state only the kit reads, plus a header the host can read: managerIdentifier, asOf, and for a pump its delivered total. - DeviceConfigurationSharing: exportConfiguration(), init?(adopting:), and isConfiguredByAnotherController, a run-time role a kit can check where it used a platform conditional. - ExclusiveDeviceControl, for hardware that allows one controller at a time: release and take control, readiness, an escalation, diagnostics and lastForeignSessionAt. takeControlNeedsSearch (also asked of an export before any manager is built) and hostRadioNeedsReset are defaulted. - DeviceManagerDelegate.deviceManagerControlDidBecomeReady, defaulted, so the host does not poll for readiness. - PumpDeliveryOdometer: the delivered total and a forced refresh. Required to hand a pump over: it is the figure both controllers' records are audited against. - DeviceManager.localizedInoperableDescription, defaulted nil: the kit's own text for why a device is inoperable, for a host without LoopKitUI. Watch build: - LoopKit-watchOS compiles PumpManager.swift, SettingsStore and the files it needs (SettingsObject+CoreDataProperties, NotificationSettings, StoredInsulinModel), so a watch can build a pump manager and know the settings it doses under. - EnactedTempBasal moves from DosingDecisionStore to its own file, so a target without the dosing-decision store can name the running temp basal. No semantic change. CarbStore, for a store that mirrors another device's entries: - addCarbEntry(_:syncIdentifier:) inserts only if absent. Records delivered over an at-least-once transport were stored once per delivery. - deleteCarbEntrySkippingAuthorshipCheck: deleteCarbEntry without the authorship gates. Glucose zombie-sample guards (watchOS only): - GlucoseStore skips an anchored-query sample whose startDate is nil. The anchor never advanced past it, so it crashed the app on every relaunch. - StoredGlucoseSample is built through a validating initializer, for a row a concurrent delete removed. Tests: SharedDeviceConfigurationTests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The watch's glucose store no longer reads from HealthKit, so the guarded anchored-query path never runs there. - The store is opened once and every read, write and purge runs on one context, so a row can't vanish mid-read on this branch. - GlucoseStore and StoredGlucoseSample are stock again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Basal - LoopKit-watchOS now builds DosingDecisionStore.swift and the DosingDecisionObject Core Data class, so a watch controller can name EnactedTempBasal and, later, record its own dosing decisions. - EnactedTempBasal.swift is gone and DosingDecisionStore.swift is stock again; the change is target membership only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- deliveryPulseUnits is the size of one delivery pulse, the increment deliveredUnits counts in. A controller auditing a lent pump's records against its delivered total needs it to floor rate records to whole pulses, and it is the pump's to state, not the controller's to assume. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Add localState: what a manager learned about its device that is this controller's own (for example its Bluetooth handle). It never travels in the export. Default nil. - init(adopting:) becomes init(adopting:localState:), and the static takeControlNeedsSearch(adopting:) gains localState too, so the host can hand the last value back. - Why: device state belongs in the manager's saved state, which the host keeps, not in a kit's own UserDefaults. Tests: LoopKit shared scheme 52 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…provenance addGlucoseSamples skipped a sample only when one with the same syncIdentifier was stored under the caller's provenance, but the cache's uniqueness constraint on syncIdentifier spans provenances. A reading stored first by one path (history sync, the phone's provenance) and then added by another (the phone's context, the watch's provenance) passed the check, merged away at save, and the read-back of the merged object trapped on a nil startDate. Sport Mode makes that sequence routine on the watch, which relaunches and replays the phone's last context far more often than the stock watch app; it crash-looped the watch app. The check now matches the constraint, so every caller's duplicate is skipped before the insert. Related: LoopKit/Loop #2475. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch now keeps the phone's authorship of the carbs it copies, so the stock delete serves it: Loop's own entries can be deleted, another app's cannot. With it goes the lookup that fell back to matching by start date and grams. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: a host that escalated a take whose link had not come up had no way to undo the escalation once the take was over, so the escalation could outlive it. - ExclusiveDeviceControl.endTakeEscalation(): undoes escalateTakeControl() once the take it served is over, reached or given up. Idempotent; does nothing by default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream now does two things this branch did, so this branch's versions go: - CarbStore: new carb entries carry their own sync identifier and a repeated add is stored once, so the separate addCarbEntry(_:syncIdentifier:) is removed. - GlucoseStore: a sample already stored under another provenance is skipped, as this branch did; upstream's version is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ExclusiveDeviceControl.endTakeEscalation(), as in LoopKit#626: an escalation's search no longer outlives the take it served. Default does nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A controller taking a pump back needs to know whether the pump is still giving a bolus, but its own bolus state knows only the boluses it started. The delivered-total protocol now also says when the bolus the pump reported at its last read will end, whoever started it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ps2
left a comment
There was a problem hiding this comment.
I'd like to land a smaller ExclusiveDeviceControl, since it becomes public API we keep. I went through how LoopKit/Loop#2545 and loopandlearn/OmnipodKit#177 use each member. I think this covers everything they do today:
public protocol ExclusiveDeviceControl: DeviceConfigurationSharing, DeviceManager {
/// Persisted, so a relaunch doesn't take control back.
var isControlReleased: Bool { get }
func releaseControl()
/// Connect, and keep reacquiring however the kit sees fit until ready or released.
func takeControl()
/// Link up and the device answered; changes reported via
/// DeviceManagerDelegate.deviceManagerControlDidBecomeReady(_:).
var isControlReady: Bool { get }
/// Whether taking control with this export and local state means finding the
/// device first (the host may need to keep its screen or radio awake).
static func takeControlNeedsSearch(adopting configuration: SharedDeviceConfiguration,
localState: [String: Any]?) -> Bool
/// The last take failed in a way only resetting the host's radio fixes. Default false.
var hostRadioNeedsReset: Bool { get }
}What changes from the current version:
lastForeignSessionAt: drop it. Only the test mock in Loop implements it, and nothing reads it.connectionDiagnostics(): drop it. The kit can write that text to the device log itself throughDeviceManagerDelegate, as OmnipodKit already does elsewhere. It then shows up in the device log alongside the handback entries instead of inside them.escalateTakeControl()/endTakeEscalation(): fold them intotakeControl(). OmnipodKit would switch to scanning by address on its own when the link hasn't come up after its own wait, and stop once control is ready or released. When and how to rescan is device-specific Bluetooth behavior, so it fits in the kit. It also means a host can't leave an escalation running by forgetting to end it. Loop'sreclaimEscalateAfterwould become a constant in OmnipodKit.takeControlNeedsSearch: keep only the static version. After adopting, the host still has the same export andlocalState, so it can ask the static one. ThefirstContactcheck in the watch's takeover would use it too. OmnipodKit's instance check can stay internal for its owntakeControl().hostRadioNeedsReset: keep it for the "Turn watch Bluetooth off and on" hint. Alternatively, the kit could issue that as a device alert and drop the member. I'm fine either way.
As far as I can tell, none of the loan, settle or audit logic changes. DeviceConfigurationSharing and PumpDeliveryOdometer stay as they are. Does that work for you, or is there something one of these members does that I've missed?
Separately, could you update the description? It still lists the carb store additions, but #654 has replaced those, and the glucose guards were dropped.
|
Description updated. Your proposal works as far as I'm concerned and can tell. Changes pushed. |
As suggested in review: the protocol keeps release, take, readiness, the static "must the take find the device first?" check, and the hint that the host's radio needs a reset. Escalating a stalled take moves into the device kit, as Bluetooth behaviour; the diagnostics text goes to the kit's own device log; the instance search check and the unused last-foreign-session date are dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applies the review of this PR more widely: public API only for what a host reads. Each kit reads its own state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Regarding |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hared isConfigurationSharingEnabled, defaulting to true, says whether the host should export a device's configuration unprompted. G7SensorKit answers with its Read on Apple Watch setting, so the phone passes the sensor to the watch only while the user has it on: reading it takes the sensor's watch slot, which the Dexcom watch app also wants.
Points LoopKit, G7SensorKit and Loop at their next-dev merges of LoopKit/LoopKit#626, LoopKit/G7SensorKit#80 and LoopKit/Loop#2605. Loop also picks up LoopKit/Loop#2606.
The LoopKit part of Sport Mode, where the watch borrows the pod from the phone and runs Loop itself. It replaces #612, from my fork so you can push edits. Nothing changes for a pump or CGM that doesn't opt in.
What's in it — attempts to create the protocols and make things more generic:
PumpConnectionLendable). One lets a device give another controller what it needs (exportConfiguration(), init(adopting:localState:), and localState: what the device learned that belongs to this controller only, such as its Bluetooth handle, which the host keeps and hands back at the next adopt). One lets a device that allows a single controller be released and taken (releaseControl(),takeControl(), readiness). One reports a pump's delivered total, which both sides audit against, as well as its pulse size. None of them mentions a phone or a watch.localizedInoperableDescription, defaulted to nil), so the app can show device faults in a device agnostic way.Worth a look:
This comes before OmnipodKit, G7SensorKit and Loop PRs which use these protocols.