Repository navigation
SportMode - Loop Glance complications - #2563
jeremybarnum wants to merge 17 commits into
Conversation
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>
ClockKit is deprecated and current watchOS no longer offers new ClockKit complications. This adds a
watchOS widget extension, Loop Complications, carrying the same two complications the app offers today:
- Loop Status (inline, corner, circular): the reading and its trend, the loop's freshness colour,
the stock inline line ("106↗ 108 8:47 PM").
- Glucose Graph (rectangular): the reading over the chart of recent and predicted glucose, drawn by the
existing ComplicationChartManager and handed to the widget as an image.
The watch app publishes a ComplicationSnapshot to the shared app group on every context update,
formatted with the same formatters and localized strings the ClockKit templates use, so the extension
links nothing of Loop's. Reloads are requested only when something shown changed and at most every five
minutes, and only for Loop's two kinds, since WidgetKit budgets reloads per widget; the timeline carries
the moments the reading goes stale and the loop's colour turns, so those need no reload. The throttle
takes any snapshot, so another publisher can keep its own.
ComplicationController gains a ClockKit widget migrator, so faces showing the old complications move to
the new ones when the app updates. The two ClockKit descriptors share the identifier "glucosegraph", so
the migrator tells them apart by family.
The watch app gains an entitlements file with the app group and an AppGroupIdentifier key. Builds that
create their identifiers outside Xcode (e.g. the browser build) will need the new extension identifier
and the app group on the watch identifier.
Tests: ComplicationSnapshotTests in LoopTests — what shows at each moment, the change moments, the
reload throttle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch glance as complications, on top of the WidgetKit port. The face editor asks for a slot's shape first, so each shape is its own widget with its own options: the reading always, then IOB, COB and the reading's age in the combinations that fit without shrinking the font, and an active override where a line has room. Units and the reading's arrow label the values (112↗, 3m, 1.2U, 10g, +0.40U/h, →140); there are no titles. Circular complications put what the circle leaves out in the bezel label, where the face has one. The rectangle offers Big BG (the reading as large as the slot allows, who holds the pod, the reading's age), balanced (the reading large, IOB and COB beneath) or Glance (every value). The reading is coloured by range, dosing values are bright and status dim, a watch or phone icon shows who holds the pod, and an override shows its own symbol. The source is the glance's: during a loan the watch's own loop, republished at every cycle; otherwise the phone's context and its override. Values dash after 15 minutes, and ages count whole minutes from timeline entries, so neither needs a reload. A background reload is granted without spending the widget's budget only while the app holds a Bluetooth connection, and then at most once per 300 s (not documented; seen in the watch's system logs). The publisher asks at the first moment the watch is connected after a reading, at least 300.1 s after the last reload the widget built while connected, checks 2.5 s later whether the widget built, and retries once in the next window. In front, any change is redrawn, one request at a time, and every app open refreshes the face. The widget records each timeline it builds, and that record, not the request, decides what the face has drawn. Tests: GlanceComplicationTests — what each shape offers and shows, the dashes and ages, and the refresh policy's windows, spacing, retry and in-front rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
29dc439 to
26058db
Compare
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
On a Sport Mode build from The watch app is signed without the App Group, so the snapshot It comes from merging the two ways of choosing the watch app's entitlements. #2562 sets
A fix:
Adding the group to |
…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>
…the App Group The complication showed "---": the watch app was signed without the App Group, so the complication couldn't read what the app shares with it. LoopKit#2562 and LoopKit#2545 each set the watch app's entitlements, and on this branch the second setting won. With Sport Mode on that was Sport Mode's file, which had no App Group; with Sport Mode off it was empty. Now one setting chooses the file, the stock file is its default, and Sport Mode's file has the App Group too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for that catch, it's fixed and pushed. |
Sport Mode: "Loop Glance" complications. Stacked on #2545 and the WidgetKit complications PR, #2562, which are merged in as they move; the net diff against #2545 shows this PR's changes together with #2562's.
What's offered. The face editor asks for a slot's shape first, so each shape is its own widget with its own options. Every option leads with the reading; units and the reading's arrow label the values (
112↗,3m,1.2U,10g,+0.40U/h,→140), with no titles.112↗ 1.2U 3m); a watch or phone icon in the tip shows who holds the pod.112↗ 3m · 1.2U · 10 g · 🏃 70% 140).Slots drawn in capitals space the grams (
10 g), so they never read as "10G". Values dash after 15 minutes.Source of truth is the glance, so a complication can never disagree with the Start screen: during a loan, the watch's own loop; otherwise the phone's context, which on next-dev carries the watch's own G7 reading.
Refresh: what we believe, from the watch's system logs (Apple documents none of it):
App in front: every reload is free and every complication rebuilds.
App in the background: a reload is free only while the app holds a Bluetooth connection, and at most once every 300 s per app. The free reload rebuilds one complication, in practice the one longest on the face. Every other Loop complication is refused ("within throttle period") and, with no budget to fall back on, stays as drawn until the app is opened. Registering separate widget kinds doesn't change this: tested, the limit is per app.
Ages and dashes advance from timeline entries without any reload.
Connected moments: the G7 link, about 1–4 s after each reading, all day. During a loan there's also the pod exchange, about 5–15 s after the reading.
What the publisher does: it asks at the first connected moment at least 300.1 s after the last reload the widget actually built, checks whether it built, and retries once in the next window. In front it asks only if the face hasn't drawn the current reading.
So in practice: one Loop complication stays live in the background, and the options are built to put what matters into that one slot. In an overnight loan with a real pod, 58 of 70 readings (83 %) reached it within a minute.
Not yet known: whether "longest on the face" is the actual rule for which complication wins; how the phone's own complication updates (off-loan) interact with this.
WatchLoopManager+Display's override label moves to a staticoverrideLabel(for:)(same output) so the publisher can use it.Tests:
GlanceComplicationTests(formatting and units, staleness, the per-shape options, the refresh policy's windows, spacing, retry and in-front rule).Related PRs
🤖 Generated with Claude Code