Skip to content

SportMode - a watch that starts without the phone tells it by Apple push - #2624

Draft
jeremybarnum wants to merge 13 commits into
LoopKit:next-devfrom
jeremybarnum:sportmode/push-notice
Draft

jeremybarnum wants to merge 13 commits into
LoopKit:next-devfrom
jeremybarnum:sportmode/push-notice

Conversation

@jeremybarnum

Copy link
Copy Markdown

Sport Mode: a watch that starts Sport Mode without the phone tells it by Apple push. Stacked on #2545; only the last commit is this PR (net diff). From my fork so you can push edits. Behind the build flag SPORT_MODE_PUSH_ENABLED; with it off, nothing changes.

The problem. When Sport Mode starts with the phone nearby, the phone knows the watch has the pod. It stops looping, and its "Loop not looping" notifications are cancelled. When the watch starts Sport Mode while the phone is away, the phone doesn't know until the two are back in touch. Until then it keeps trying to loop and keeps firing "not looping" notifications. Those are mirrored to the watch by iOS, which reaches a paired watch over Bluetooth, a shared Wi-Fi network, or, with a cellular watch, the internet. So the watch, which is looping, is told repeatedly that Loop isn't. That's distracting. The phone also still believes it holds the pod, which is a dual-control risk.

What this does. If the watch has its own internet (cellular, or Wi-Fi it knows), it sends the phone one silent Apple push right after it takes the pod. The push wakes the phone, and it stands aside exactly as it would on hearing from the watch in person: it stops looping, holds its dosing, and its notifications stop. Nothing is shown; the phone and watch log it. There is one attempt. If the push is lost, the existing path still applies: the phone stands aside when it next hears from the watch.

The credential. Apple push needs an APNs key from the Apple Developer team the app is built with, the same kind of key Nightscout uses for Loop's remote commands. The user pastes it into Settings ▸ Sport Mode ▸ Push Key on the phone. It's kept in the phone's keychain and goes to the watch inside the standing copy the phone already sends, with the phone's push address. The team ID comes from the build. No key is built into the app, and no server is involved. Each push carries the phone's offline-start credential, so the phone acts only on a push for a Sport Mode start it allowed, and ignores one older than 10 minutes.

Setup. Users will need instructions. Creating the key is the same as for Nightscout remote commands: LoopDocs, Apple Push Notifications. Without a key, nothing changes.

Follow-up, not in this PR. The obvious next step is sending the watch's insulin and carb records by push, so a distant phone stays current, which would mitigate risk. Once dosing data travels by push, though, a lost, late or repeated delivery becomes a dosing question, so that's left for its own decision.

Stock code touched:

  • LoopAppManager, 3 lines: a Sport Mode push is handled before the services see it.
  • SettingsView, 3 lines: the Push Key row.
  • FeatureFlags.
  • Info.plist: the team ID.

🤖 Generated with Claude Code

Jeremy Barnum and others added 13 commits October 27, 2026 23:51
The watch needs the same types to run the same loop as the phone. They move unchanged from the Loop app
to LoopCore: the algorithm input (StoredDataAlgorithmInput), the dosing decision record
(BolusDosingDecision), LoopConstants, LoopError with its messages and translations, LoopWarning,
SimpleInsulinDose, CarbEntryChange, the four store protocols, SettingsProvider, the DosingDecisionStore,
PumpManagerError and TempBasalRecommendation extensions, and LoopDataManager's conversions of carb
entries and glucose samples for the algorithm. The algorithm input gains a public initializer, because
the Loop app still builds it until the next commit. Files that use them import LoopCore. Nothing else
changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sport Mode runs Loop's loop on the watch. Rather than a second copy of LoopDataManager.loop() there, the
loop moves into LoopCore, where both apps run it.

- LoopCycle holds the body of loop(): the input checks, the algorithm run, the dosing decision and its
  record, and the enact. It asks its host only for what the device knows: the stores, the settings and
  overrides, the algorithm experiments, the dosing strategy, whether dosing is held and whether the loop
  is closed. LoopDataManager is the phone's host.
- The manual bolus recommendation, cancelling the loop's temp basal, the display run and the remote
  recommendation go through the cycle too.
- The pump-facing members DeviceDataManager had for delivery (the pump's state, rounding, the checks
  before a dose is enacted) become PumpManagerDeliveryDelegate in LoopCore; each device sends the checked
  dose its own way.
- The dosing record of a bolus asked for on the watch comes from one builder,
  BolusDosingDecision.watchBolus, which WatchDataManager now calls.

The phone behaves as before, and its tests pass. One difference: the watch bolus record takes every field
from the display state read at the start, where before it read some fields before computing the
recommendation and some after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lending the pod to the watch needs a protocol both apps share, and one place that decides, in each
situation, which device loops, which stands aside and which asks.

- LoopCore/PodLoan: the messages (the request and grant, the takeover, records, the hand-back offer and
  its acknowledgement, the take-back, a status question and its answer), how they travel (live when the
  watch is reachable, and queued as well where a message must arrive), and the loan's records.
- LoanControl: the control decisions, one function per situation. Each is a row of the control document,
  Documentation/SportMode/Control.md, which sets out the situations, the principles behind each decision
  and the known limits.
- The SPORT_MODE_ENABLED build setting, off by default. With it off, neither app runs any of this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The phone's side of a loan, in its own files, on the main actor like the phone's managers.

- Start: the phone lends only when it may (no bolus being given, no command to the pod unconfirmed,
  settings complete). It releases the pump and sends a grant: the pump's exported configuration, the
  settings, the glucose alert settings and dosing strategy, and recent insulin, carbs, glucose and
  overrides.
- During the loan it doses nothing, stores the watch's records once each as they arrive, a temp basal
  still running once it has ended, and checks the pod's delivered total against them.
- The pod comes back at a hand-back, when the user takes it back, or when the watch goes quiet and does
  not answer. The phone imports the records, checks the insulin and books an estimate for any the records
  cannot explain; later records replace it.
- A loan the watch started without the phone, from the copy of the grant the phone keeps on the watch
  for that: when the phone hears from the watch again (its records, a hand-back offer, or its answer to
  the phone's question) about a loan this phone never granted, it stands aside, holding its dosing and
  releasing the pump. If the loan began before this phone last took the pod back because the watch went
  quiet, the phone tells the watch to stop instead. If its pod driver keeps the pod because the phone's
  last command is unconfirmed, the phone reads the pod, which settles the command, and the driver lets go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Everything Sport Mode changes in the phone's stock files, together, so it can be read in one place. Each
hook does nothing on a phone that never lends its pod, except the Predicted Glucose screen's redraw below.

- DeviceDataManager: a pump report runs the loan's insulin check before it completes; Loop's diagnostic
  report includes the loan's state.
- LoopDataManager: automatic dosing is held while the pod is lent.
- AlertManager: the Loop Failure warnings stop while the pump is released.
- GlucoseAlertManager and MealDetectionManager: while the watch raises this user's glucose alerts, the
  phone evaluates none, and starts its episodes afresh afterwards.
- WatchDataManager: loan messages go to the loan controller; a bolus sent from the watch while the pod is
  lent is refused.
- LoopAppManager: the wiring, and banners in the foreground for the loan's notices.
- PredictionTableViewController: the Predicted Glucose screen redraws when insulin, carbs or the forecast
  change, as the status screen does. During a loan the watch's records arrive without a glucose reading,
  so the open screen would otherwise fall behind until the next one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Until now the watch app has been a display for the phone's loop. Its screens show the summary the phone
sends after each cycle (insulin and carbs on board, the forecast, when the loop last ran), with the
watch's own glucose reading in place of the phone's when it is newer, and the app keeps glucose and carb
stores only to draw the chart and the carb list. A watch that runs the loop itself, as Sport Mode's does
in the next commit, needs its doses and dosing decisions in stores too, and its screens need to show that
loop instead of the phone's summary. This commit prepares both, so that Sport Mode builds on the stock
watch app rather than running beside it with stores and screens of its own.

- The watch's LoopDataManager opens dose and dosing-decision stores, with the alert history and device
  log, beside its glucose and carb stores, in the watch's usual locations. In Sport Mode the loop and the
  screens use the same stores.
- Every screen reads one display state, which until now was used for the glucose reading alone: the
  phone's summary, with the watch's newer reading in its place. Sport Mode, in the next commit, lays the
  watch's own loop over it.
- A fix: the carb list asks the phone for its carbs when it opens and when the phone's summary changes.
  Since the carb list moved to SwiftUI, nothing has asked, so the list could stay empty.

Without Sport Mode the code changes, but the watch app looks and works as before, apart from that fix to
the carb list. The simulator tests check the watch app with Sport Mode built in and with it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the previous commits the watch can run Loop's loop cycle on stores of its own, but it has no pump
to dose with: the pod belongs to the phone. This commit adds the watch's side of a loan, in its own
folder, on the main actor.

- WatchLoopManager runs the shared loop cycle on the watch, over the pump the phone lends. Its cycle work
  runs one step at a time, in the order it was asked for, as on the phone.
- PodLoanWatchController handles the loan from the watch's side: Start, with the phone or, when the phone
  can't be reached, from the copy of the grant the phone keeps on the watch; the takeover of the pod;
  records sent to the phone as checkpoints during the loan; End and the hand-back; standing aside when the
  phone takes the pod back; and resuming after the watch app relaunches, which checks with the phone first
  and counts any insulin given while the watch was off before it doses again.
- During a loan the watch raises the glucose alarms (with Loop's own GlucoseAlertManager, compiled for the
  watch) and the pod and Loop Failure alerts. It keeps its dosing decisions, alert history and device log,
  which go to the phone in one file when the loan ends.
- Hooks in stock watch files: the extension delegate builds the Sport Mode stack and holds loan messages
  that arrive before it is ready; LoopDataManager hands each stored glucose reading to the loop, and shows
  the loop's state and override on the screens during a loan; WatchCGMHost reports unreliable sensor data,
  so the loop cancels a high temp basal, as the phone does.
- The watch's entitlements come from a build setting, empty by default, so without Sport Mode the watch
  signs as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the previous commits a loan runs end to end, but nothing on either screen starts one, ends one or
says one is running: the watch's pages and the phone's status screen look as they do without Sport Mode.

- Watch: the Sport Mode glance, a page after the watch app's usual two, which shows glucose and trend in
  the user's unit, the loop ring, insulin and carbs on board and the running temp basal, with Start and
  End. The watch moves to it when a loan starts, when the app relaunches into a loan, and after carbs or a
  bolus are entered during a loan; outside a loan the app opens where it always has.
- Watch: during a loan, carbs can be deleted from the carb list. A carb entry drives the dose, so the user
  needs a way to correct a wrong one while the watch has the pod; deleting gives that now, and editing is
  left for later. A bolus or an End that is refused says why on the screen. With Sport Mode built in, the
  check that the phone is set up applies page by page, so the glance stays reachable when it is not.
- Phone: a "Sport Mode Active" banner, which says when the watch's records last arrived, and a pod tile
  that say the watch has the pod and offer to take it back; carbs, bolus, presets and settings
  unavailable while it does; the loop's status ring stops ageing, and its explanation says the watch is
  running the loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oan the watch started without the phone

Follows up the review point that, during Sport Mode, the phone's screens leave out what the watch has
done, even when the two are in touch. The earlier commit answered it by saving the watch's records on the
phone as they arrive and showing on the banner how current they are. It missed two things:

- The banner's time moved whenever the watch said anything, even when it sent no records. So the banner
  could say the phone was more current than it was. Now it moves only when the watch's records are saved
  on the phone, and reads "Insulin and carbs as of …".
- If the watch started Sport Mode while the phone was away, the phone ignored the watch's records even
  after the two were back in touch, until Sport Mode ended. Its insulin and carbs stayed as they were when
  it left, and the banner showed no time. Now, once they are in touch, the phone takes the watch's records
  and saves them as they arrive, as it does when Sport Mode starts with the phone nearby. For this, the
  watch includes the phone's offline-start credential with its records. Until the first records arrive,
  the banner shows when the phone last read the pod itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… by Apple push

When the watch starts Sport Mode while the phone is away, the phone doesn't know until the two are back
in touch. Until then it keeps trying to loop and keeps sending "Loop not looping" notifications, which
reach the watch that is looping. If the watch has its own internet, it now sends the phone one silent
Apple push right after taking the pod, and the phone stands aside as it would on hearing from the watch
in person. The push is signed with an Apple push key the user saves in the phone's settings, which
reaches the watch with the standing copy. It is behind the build flag SPORT_MODE_PUSH_ENABLED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…of it

We're narrowing down which Apple Watch generations work with Sport Mode: an SE and a Series 9 do, a
Series 5 doesn't. On a Series 7 the grant never reached the watch while smaller messages got through,
and the phone's log couldn't say why: the grant goes urgently and queued at once, and a failed urgent
copy wasn't logged. The phone now logs the grant's size and whether it went urgently or queued only,
and a failed urgent send of any message that also rides the queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant