Skip to content

SportMode - necessary LoopKit changes to allow a watch controller - #626

Merged
ps2 merged 18 commits into
LoopKit:next-devfrom
jeremybarnum:sportmode/next-dev
Oct 24, 2026
Merged

ps2 merged 18 commits into
LoopKit:next-devfrom
jeremybarnum:sportmode/next-dev

Conversation

@jeremybarnum

@jeremybarnum jeremybarnum commented Oct 1, 2026 •

Copy link
Copy Markdown

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:

  • Hand-off protocols (replacing Sport Mode, part 1/5: LoopKit — glucose zombie guards, PumpConnectionLendable, mirror-store carb entries #612's 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.
  • A fault description from the device (localizedInoperableDescription, defaulted to nil), so the app can show device faults in a device agnostic way.
  • The watch now keeps its own dosing decisions; see the Loop PR.

Worth a look:

This comes before OmnipodKit, G7SensorKit and Loop PRs which use these protocols.

…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>
Jeremy Barnum and others added 4 commits October 1, 2026 14:56
- 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>
Jeremy Barnum and others added 6 commits October 13, 2026 18:43
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>
EJM0 added a commit to EJM0/LoopKit that referenced this pull request Oct 19, 2026
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 ps2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. lastForeignSessionAt: drop it. Only the test mock in Loop implements it, and nothing reads it.
  2. connectionDiagnostics(): drop it. The kit can write that text to the device log itself through DeviceManagerDelegate, as OmnipodKit already does elsewhere. It then shows up in the device log alongside the handback entries instead of inside them.
  3. escalateTakeControl() / endTakeEscalation(): fold them into takeControl(). 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's reclaimEscalateAfter would become a constant in OmnipodKit.
  4. takeControlNeedsSearch: keep only the static version. After adopting, the host still has the same export and localState, so it can ask the static one. The firstContact check in the watch's takeover would use it too. OmnipodKit's instance check can stay internal for its own takeControl().
  5. 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.

@jeremybarnum

jeremybarnum commented Oct 21, 2026 •

Copy link
Copy Markdown
Author

Description updated. Your proposal works as far as I'm concerned and can tell. Changes pushed.

Jeremy Barnum and others added 2 commits October 21, 2026 14:38
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>
@jeremybarnum

Copy link
Copy Markdown
Author

Regarding hostRadioNeedsReset there wasn't enough evidence that the solution was needed for the problem - it was legacy from before directAuth. So I just got rid of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jeremy Barnum and others added 2 commits October 22, 2026 19:32
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants