Repository navigation
SportMode: Loop changes needed to run autonomously on the watch - #2545
Draft
jeremybarnum wants to merge 137 commits into
Draft
jeremybarnum wants to merge 137 commits into
jeremybarnum wants to merge 137 commits into
Conversation
The phone lends the Omnipod to the watch. The watch runs Loop itself (its own G7 glucose, its own stores, the stock algorithm) and doses the pod until it hands it back. The phone stays the home of the user's data and takes the pod back on demand. Loan protocol (LoopCore/PodLoan, compiled into the phone, the watch and both test targets): - LoanMessages (grant, hand-back, revoke, acks, status reports), LoanRecords (the dose, carb and override records that move between devices) and LoanTransport over WatchConnectivity. - The envelope carries a protocol version and throws on a mismatch. Same-version field changes ride capability flags, because the two apps install separately and a watch newer than its phone is routine. Phone, the lender (PodLoanPhoneController and its extensions, LoanReconciler): - Grant: releases the pod's connection and sends its state and the therapy settings. A standing copy on the watch lets the watch start without the phone. - Hold: while the pod is out, the phone's loop computes but never enacts. - Records from the watch are stored idempotently by sync identity. Reconciliation audits the pod's delivered total against the books, and opens the loop and books a placeholder for insulin it cannot explain. - Reclaim and settle: a hand-back or a forced reclaim, a status read to verify, then a resumed loop. A silent watch is warned about, never taken from. - Hooks in stock files: DeviceDataManager, LoopDataManager (the enact interlock), WatchDataManager, AlertManager, NotificationManager, TemporaryPresetsManager, StatusTableViewController. SettingsProvider and LoopUpdateContext move to their own files so the watch can compile them. Watch, the borrower (WatchApp Extension/StockLoop): - StockLoopStack and StockLoopSession: the watch's own glucose, dose and carb stores, a G7 manager reading the sensor directly, and a pump only during a loan. - WatchLoopManager: the Loop cycle with the stock algorithm and the granted settings. Missing settings deny dosing; nothing is defaulted. - PodLoanWatchController: takeover, hand-back, revoke, resume after a relaunch, and a start from the standing copy when the phone cannot be reached, with a prompt when it returns. - An HKWorkoutSession keeps the app running across takeover and hand-back. - The glance, carb and bolus entry during a loan, wrist alerts for a stalled loop and a stuck hand-back, a diagnostics page and a device log. Tests: LoanProtocolV2Tests, LoanBooksHarnessTests and PodLoanPhoneControllerTests in LoopTests, and a WatchAppTests target that reaches the watch extension's own code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… G7 fixes Phone, while the pod is loaned to the watch: - A "Sport Mode Active" banner in the status rows; tapping it offers the reclaim, or the settling notice while a reclaim is in flight. - Add Carbs, Bolus and Presets are disabled and dimmed. - The loop circle stays fresh with no minutes caption, and its dialog says the watch is running the loop. - Charts show history only: no forecast or "Eventually", blank Active Insulin and Active Carbs values, IOB and COB curves cut at now. A loan starting or ending refetches everything. Automatic bolus on the watch: - The grant carries the phone's dosing strategy instead of forcing tempBasalOnly, and the watch runs the matching recommendation type. - A cycle whose temp needs no command still enacts its automatic bolus; boluses are rounded to the pump's volume and drive the glance's delivery bar. - LoanDoseRecord.automatic carries the flag home, so the phone stores watch automatic boluses as automatic. Feature flag: sportModeEnabled (SPORT_MODE_ENABLED, default off) gates the watch's Sport Mode and diagnostics pages. Watch G7: - While the watch searches for a sensor it has never connected to, the glance and diagnostics ask the user to keep Loop open, and a pre-scheduled "Sensor Not Found" alert fires if the app stays in the background for ten minutes. - A persisted sensor is past its life only after its reported session end, or 15.5 days when the length is unknown, so a 15-day sensor in its grace period is no longer discarded at launch. - Bluetooth wake tasks are never completed twice; a redelivered task threw mid-pairing and crashed the app.
The glance, diagnostics page and sensor-search alert read watchNeedsCodeFor and watchIsSearching from the stack's G7CGMManager and observe G7CGMManager.watchStatusDidChange. The diagnostics page no longer offers the re-lodge picker.
… stock Device hand-off contract: - The grant carries the pump's exported configuration (SharedDeviceConfiguration) instead of its raw state. LoanProtocol.version is 3, so a version-2 peer is refused rather than guessed at. - WatchDeviceManagers builds the pump and the CGM through init(adopting:), keyed by plugin identifier. The CGM is built from the phone's export on the watch context and rebuilt only when that export changes. - Gone with it: the watch's edits to the pod's raw state, its handle cache, the device-specific loan calls and the G7 state reads. Also gone: the launch-time G7 past-life discard and the forgotten-sensor guard. G7SensorKit keeps a passed sensor itself and knows each sensor's own session length. - Takeover readiness comes from deviceManagerControlDidBecomeReady. The pre-Start "keep your wrist up" note asks takeControlNeedsSearch(adopting:) of the standing copy. Loan state: - PodLoanPhoneState and PodLoanWatchState hold each side's persisted loan fields as one value, saved before any send that depends on them. The watch's loop mode, correction model and last cycle are one WatchLoopState. The loaned pump's state is saved the way stock saves a pump manager's. - Fixed on the way: the grant's audit base was saved under the previous loan's epoch; the watch never saved the phase its launch normalised; a seized loan's reunion token was saved after its active phase. - Injectable storage seams for tests. Hand-off behaviour: - Take-back: a deferred force is satisfied by the close, and a yield closes the settle window. - Offline start: a confirm with the phone back is a normal request, and a grant or a new Start withdraws the offer. - First contact with a new pod asks for the wrist up and says when it can come down. - No whole-loan workout session. The watch's bolus progress comes from the pump manager's reporter. Wrist overrides are journaled again. Alerts during a loan: - Glucose alarms on the wrist: stock GlucoseAlertManager compiled into the watch, built from the phone's alert settings carried in the grant, fed direct G7 readings and the watch cycle's forecast. - A pod fault the watch reports gives one phone notice per fault, with the kit's own fault text (localizedInoperableDescription). - OK or dismiss on the loaned pump's wrist alert acknowledges it on the pump, which is what stops an Omnipod beeping. - Only what is critical on the phone becomes time-sensitive on the wrist. The loan's own notices are time-sensitive when nobody is dosing or insulin is booked wrong, and normal when they are routine. - Any behaviour-relevant settings change, glucose alert settings included, refreshes the watch's standing copy at once. Back to stock and lean: - SPORT_MODE_ENABLED switches Sport Mode off at the entry points on both apps, not just its pages. - Back to stock: the configurable development team, project settings (eager linking, the watch simulator's x86_64 exclusion), two stock-bug fixes in LoopAppManager. - Removed: code only earlier test builds needed (migrations, the iCloud log mirror, a demo gallery), the log-only start gate, the phone's residual history, unused device-kit imports, Siri from the watch entitlements, and a test file for a feature not in this tree. - Comments state intent in a line or two. Comments, log strings and identifiers carry no internal tags; temp-cancel reasons and placeholder sync identifiers have device-neutral names; the phone's string catalog matches its strings. Tests: one for each change above; racy tests wait on their condition; loan tests no longer write into the app's real Documents or notification centre. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Gate: the phone loop's existing loan hook (`isPumpConnectionReleased`) now reads the controller's `holdsAutomaticDosing`, true while the pump is released OR a force-reclaim audit is still owed. Before, `takeControl()` lifted the only gate at the force reclaim, so the phone could enact a temp or automatic bolus on books missing the watch's insulin until the audit ran. - The owed audit is already persisted state, so the hold survives a relaunch; the settle window's five-minute ceiling still ends it by opening the loop. - The hold lifts after the verdict is applied, so a loop-open on a breach is dispatched first. - Test: the dosing-hold test asserted only the notification pause; it now asserts the gate the loop reads, and fails against the release-only gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A wrist carb is now stored on the watch and journaled under one identity, its event ID, which is what the phone stores it under. Before, the watch's store minted its own sync ID, so deleting a carb an earlier interim drain had committed missed on the phone and the carb stayed. - Wire unchanged: the delete record's existing `syncIdentifier` now carries the right value. No protocol version bump; an older phone matches it the same way. - The phone's match moves into `DeletedCarb.matches`, shared by the store wiring and tests. - Comments that said carbs have no identity now give the real reasons. - Tests: a watch test from carb entry to delete record to the phone's match; a phone test for a carb committed by an interim drain and removed by a later delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The grid-delay arm is gone from G7SensorKit; remove the tests that pinned its arithmetic and the arm toggle. - Pin the remaining hold decision through relodgeHold(sinceLinkUp:). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- needsCodeForSensor, isSearchingForSensor and G7CGMManager.statusDidChange replace the watch-prefixed names in the sensor-search alert, the glance note and the diagnostics page. - The diagnostics page no longer reads the G7 bluetooth manager's isConnected and isScanning, which wait on its queue; it shows what the CGM manager publishes: last reading, code needed, searching. The "link" row goes. - The "Reconnect sensor" button goes with the kit's reconnect action. - Tests: a refused connect is retried after the back-off every time (no stand-down after two); the watch's bluetooth manager carries the acquisition arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LoanReconciler.expectedInsulin takes the pulse size as a parameter; the hard-coded Omnipod 0.05 U is gone. Rate records are still floored to whole pulses, now of the lent pump's size. - The phone controller reads it from the lent pump through PumpDeliveryOdometer, which it already requires to lend. With no pump there is nothing to judge against: the force-reclaim audit takes its existing "cannot verify" path (the loop opens), a checkpoint is skipped, and a hand-back sets no pending audit. None of these could reach a verdict before without the phone's own pod read, so no outcome changes. - The hand-back log's pulse-floored drain total uses the same size in place of its own x20/20 rounding. - The watch's check of a seized loan's standing copy takes the size from the pump it just read. - Behaviour is unchanged for Omnipod: the pod reports 0.05 U. Tests: the reconciler, harness and phone-controller tests and the test pump use 0.05 U. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The watch no longer splits G7's sync identifier to match a relayed phone reading against its own reading of the same sample. The watch's adopted G7 manager takes the sensor's activation from the phone's export, so both devices name a reading alike, and both watch paths write to the one store, under its one provenance. Stock GlucoseStore.addGlucoseSamples already skips a sample whose sync identifier that provenance has stored. - The relay path counts a reading as delivered only when the store kept it. - Narrow edge: if the phone exports before it knows the sensor's activation, the watch latches its own estimate, and the two names differ until the next export; until then a reading both devices deliver can be stored twice. Tests: a relayed phone reading and the watch's own reading of the same sample, read through the adopted G7 manager, are one stored row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Carb and glucose window: stock's 12 h plus a minute, not 10 h. Stock reads it from `LoopConstants.maxCarbEntryPastTime`, which is phone-only, so the value is repeated. - ISF and override history start at `min(neededSensitivityTimeline.start, carbsStart)`, as in stock. Glucose can start later than the oldest carb (a CGM gap, or the watch's 4 h glucose cache), and CarbMath traps on a carb the ISF timeline does not cover. An override over that carb now scales its ratio as well. - Still different from stock `fetchData`, on purpose: the pump-data recency gate lives in the fetch; doses are trimmed per dose (no forward credit) and ongoing doses are not projected; no pre-meal "ending now" for a carb-entry bolus and no high-needs suspend floor; missing settings throw instead of defaulting. - Also different, not changed here: the automatic-bolus application factor is LoopAlgorithm's default 0.4, which is stock's value unless glucose-based application is on (a phone setting the grant does not carry); settings are the grant's snapshot projected over the window, not settings history. Test: hourly ISF items, 3 h of glucose, a 6 h-old carb under a 50 % override that has ended. The ISF timeline covers the carb, its ratio and ISF are doubled, and the algorithm runs (before: CarbMath trap, confirmed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `StockLoopStack` sets `TemporaryScheduleOverrideHistory.relevantTimeWindow` to 24 h when it builds the watch's history, enough for the algorithm's ~18 h lookback. The watch ran on LoopKit's 1-second default, so every glance or HUD refresh pruned an ended override and its minutes went unscaled from the next cycle: after an exercise preset, more insulin. - The window is a LoopKit static. The phone sets its own (90 days) in `TemporaryPresetsManager`, which compiles into the iPhone `Loop` target only, so the two values never meet in one process. - Each grant starts the watch's history over before applying the granted override. With ended overrides kept, an override the phone sends again (same identifier) would be stretched back over a wrist override the phone never heard of, and LoopKit traps on overlapping overrides (reproduced in the test). Starting over is what the 1-second window did in effect. Tests: an override that expired 30 min ago still halves basal and doubles ISF and carb ratio for its minutes after a refresh (before: unscaled); a grant re-sending an override after a wrist override does not trap and keeps only what the phone sent (without the reset: trap). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `LoanGrant.overrideHistoryRaw`: the phone's overrides overlapping the last 24 h, ended ones included, as a binary plist of stock raw values inside the JSON envelope, like the override record. The raw form drops `actualEnd`, so an early end is folded into the duration (as `queryByAnchor` does) and deleted overrides are left out. The standing copy is the same grant, and `withEpoch` passes the field on for a phoneless start. - The watch adopts it at intake, before the first cycle, each override at its original start and end, so an override that ended before Start still scales its minutes. A grant without the field (an older phone) leaves the watch as before: the active override only. - Version skew: an optional field, no capability flag. An older watch's decoder skips the unknown key and behaves as today; an older phone sends nil. Neither side can misread the other, which is what the envelope's "optional means an older build" rule covers; no flag, no version bump. - Standing-copy refresh: not extended. Its fingerprint already includes the active override, and every change the phone can make to its history (set, change, clear, natural end) changes that. - Size: three overrides are 945 B of plist and add 1.3 KB to the grant's JSON (measured in the test), beside grants of about 25 KB and the 60 KB urgent-channel limit. Tests: the phone's grant asks from 24 h back, folds an early end, drops a deleted override; the field round-trips, is nil from an older phone, and an unknown grant field does not break decoding; on the watch, an override that ended 2 h before Start halves basal and doubles ISF and carb ratio for its minutes on the first cycle; a start from the standing copy keeps it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The watch's override history is saved in the loop manager's persisted state on every change, through stock's `TemporaryScheduleOverrideHistoryDelegate` callback, as LoopKit's own Codable events: the 24 h window, ended overrides included, each with its actual end. - A resume reads it back before the pump manager is rebuilt, so before the first cycle, and makes the override active in it current again, as stock's `TemporaryPresetsManager` does at launch. Before, a resumed loan dosed with no override at all: the history lived only in memory and the resume state held settings and flags. - Only a resume reads it. An ordinary launch starts empty and the next grant replaces the history anyway. Opening the history through a SwiftData container, as the phone does, would also work but brings SwiftData into the watch extension. Tests: a relaunch mid-loan brings back the active override and one that ended before it (without the restore: no override, empty history); the resume checklist test now includes the override. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An override change journaled on the wrist now reaches the phone's apply path with its time: `LoanReconciler.OverrideChange` carries the record's date, `.set(_:at:)` and `.cleared(at:)`. - The phone records the change in its override history at that time with stock's `recordOverride(_:at:)`, then sets `scheduleOverride` as before; the setter's own record then has nothing left to do. This is a sibling, `TemporaryPresetsManager.setScheduleOverride(_:changedAt:)`, in the loan wiring; the stock setter is unchanged. Before, a wrist clear drained at hand-back ended the phone's override at the hand-back, so the phone's next cycles treated the minutes in between as overridden (after an exercise preset, less insulin than it should). A wrist set already kept its own start date. - Idempotency unchanged: committed event IDs and the syncIdentifier check. One guard added: a clear made before the phone's current override started does not end it. Recorded at its own time, such a clear would delete the newer override from the history. Tests: end to end, an override set on the phone, a loan, a wrist clear an hour before the hand-back: the phone's history ends the override at the clear; a replayed drain still applies once, at the wrist's time; a clear older than the phone's override is skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Therapy settings can't change on the phone during a loan, matching Carbs, Bolus and Presets; settings reach the watch only with a grant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `LoanOverrideTests` was missing from the phone gate's test list, so two of its tests had been failing unseen. - `testOverrideReachesTheWristInTheGrant` asserted that the schedules ride in `LoopSettings.rawValue`. Stock's raw value has never carried them; the grant sends them in its settings supplement. The test also built a grant by hand, so it never exercised the phone. Replaced by `testTheGrantCarriesTheActiveOverrideInItsOwnField` in `PodLoanPhoneControllerTests`: the phone's own grant carries the active override, with its identity, in `activeOverrideRaw`, and the schedules in the supplement, not the settings blob. - `testPhoneApplyIsIdempotentAcrossAReplayedDrain` cleared at the same instant the override started. The wire rounds dates to whole milliseconds, so about half the time the clear landed just before the start and the newer-override guard skipped it. The clear is now a minute later. - Two comments still described the override riding in the settings blob; corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The loan controller drains the wrist's journal on its own queue and applied override changes from there, into `TemporaryPresetsManager`, which is main-actor state. The wiring now hops to main with `DispatchQueue.main.async`, as its other settings writes do, through a sibling of the setter, `setScheduleOverrideOnMain(_:changedAt:unapplied:)`. - Async, so the controller's queue never waits on main. The main queue runs blocks in the order they were queued, so changes land in the order the wrist made them. - Idempotency: the controller decides on its own queue from what the phone holds, which it read synchronously before. A change on its way to main is now held in a small locked list until main applies it, and the wiring's reader answers with the newest one, so a drain right behind another still sees the first one's change (otherwise a clear right behind a set would read "no override" and be skipped). Committed event IDs and the syncIdentifier check are unchanged. Tests: two changes queued from a background queue apply nothing there and show as unapplied, then apply on main, set before clear, with the history ending the override at the clear's time. Fails when the apply runs on the calling queue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `LoanGrant.settingsHistory`: at the grant, the phone runs stock's own settings-history queries (`getBasalHistory`, `getInsulinSensitivityHistory`, `getCarbRatioHistory`, `getTargetRangeHistory`) over the 24 h up to it and sends the resulting timelines, as stock's `AbsoluteScheduleValue`s with glucose in mg/dL. The standing copy is the same grant, and `withEpoch` passes the field on for a phoneless start. - On the watch, `WatchSettingsProvider` answers each history query from the phone's timeline for the part of the window it covers and from the granted snapshot for the rest, clipped so the result is continuous. Before, the snapshot was projected back over the whole window, so a basal, ISF or carb ratio change made on the phone shortly before Start was applied to doses and carbs from before it. - A grant without the field (an older phone) leaves the provider as before, snapshot only. - The history is saved with the granted settings, so a resume after a relaunch keeps it. - No mid-loan update: Settings is disabled on the phone during a loan. The standing copy's refresh fingerprint already covers the schedules, so a settings change refreshes the copy and its history with it. - Version skew: an optional field, no capability flag, no version bump, as for the override history. An older watch's decoder skips the key; an older phone sends nil. - Size: a day with one change, basal in 6 blocks, adds 1.8 KB to the grant's JSON; with hourly basal, 3.1 KB (measured in the test). Grants are about 25 KB, under the 60 KB urgent limit. Tests: built by stock's queries over a real settings store with every value changed 2 h before the grant, the field round-trips, and is nil from an older phone; the phone's grant asks for the 24 h up to it; on the watch, after a change 2 h before Start, basal, ISF, carb ratio and target read the old values before it and the new ones after, continuously across the window, in the provider and in the first cycle's input (both fail with the history ignored); a grant without the field projects the snapshot back; the standing copy and a resume keep the history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`G7RelayDedupTests` built the phone's name for the relayed reading with `t / 3600`. G7SensorKit computes the hours as minutes then hours, `t / 60 / 60`, and the two round differently for about a quarter of timestamps. When they did, the watch's own reading of the same sample got a different name, the store kept both, and the test saw three rows. It failed 3 of 7 full runs. Test-only: on devices both names come from G7SensorKit's one formula, and the activation date reaches the watch exactly (property lists over WatchConnectivity and in the watch's saved files keep full precision). Checked: 40 of 40 runs pass; with the old formula, 12 of 40 fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- PodLoanWatchController keeps each pump kit's localState in its own file, PumpLocalState, keyed by managerIdentifier. It is saved wherever the manager's state is saved and is not cleared when the loan ends. - Start and the first-contact note pass it to the adopt and to takeControlNeedsSearch. Resume still restores from saved state, as stock does. - A state update that arrives after teardown (the release clearing an unproven handle) is saved too; that is the point. - The CGM adopt passes nil. The phone side only updates its test double. - Why: device state belongs in the manager's saved state, which the host keeps, not in a kit's own UserDefaults. Tests: WatchAppTests 159 passed, including two new FirstContactNoteTests (handle survives teardown and reaches the next adopt; an unproven handle is dropped at release); phone loan suites 152 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch now runs stock's three algorithm passes, each fed as stock feeds it, from a labelled copy of stock's fetchData. - Automatic loop: running temps cut at now and a bolus in flight counted whole (was pro-rated); stock's input checks, adding future-dated glucose; temp rate rounded down by the pump manager (was nearest); stock's enact refusals, adding a faulted pod and a running manual temp; last loop completed moves only on a closed-loop cycle. - Recommended bolus: a running temp credited through its end (was cut at now); a pre-meal preset ends when bolusing with carbs; no pump-data check; no longer changes what the screens show. - Both dosing runs: a very-high-insulin-needs preset raises the suspend threshold. - Display: stock's display run now feeds the glance (IOB, COB, eventual), the stock watch pages and the complication, replacing the watch's own IOB calculation. Diagnostics shows the loop run. - Kept deliberately: enact follows the watch's loop switch; a recommendation older than five minutes is refused; the stall watchdog refreshes on any error-free cycle. Tests: StockRunsTests (16, each sabotage-verified); four suites moved to the new runs. WatchAppTests 175/175, phone gate 152/152. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The display run (glance IOB, COB and eventual, and the stock pages' context) now refreshes on stock's triggers: a change in the watch's carb, glucose or dose store, an override set or cleared, and a change of loop mode. Before, it ran only at the end of each cycle, at takeover, when the grant's glucose landed and on resume, so the glance could lag a wrist bolus or carb entry by up to a cycle. - Each trigger runs the existing display pass and republish on the data access queue, with no debounce, as stock. The run reads the stores and writes none, so it cannot retrigger itself. - Only while a pod is held: between loans a store change leaves the display run alone, as before (the context publish already required a pod). Tests: StockRunsTests +3 (a dose store change and a carb entry each update the glance IOB/COB with no loop cycle; between loans nothing runs), each sabotage-verified. WatchAppTests 178/178. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch stores dosing decisions as stock does, to match stock and to prepare for uploading from the watch. Until then they go home in one file at hand-back and the phone uploads them. The phone's loop keeps running during a loan without dosing; it no longer stores decisions for those cycles. Tests: StockRunsTests +6, PodLoanPhoneControllerTests +2, LoanDosingDecisionTests (new, 4). WatchAppTests 184/184, phone gate 158/158. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch app now sets LoopLocalCacheDurationDays from the same build setting as the phone (90 days). Without it the watch fell back to one day, so decisions not yet sent home could be purged during a loan longer than a day, or before a watch revived after a force reclaim sent them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An earlier test's controller can still post an alert into the shared scheduler while these run; the failed-acknowledgement test then counted two pending requests instead of one (1 failure in a full run). The tests now look only at requests for the alert they presented. Checked: 3 full runs 184/184; with the put-back removed, the failed- acknowledgement test fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch records the alerts it issues, acknowledges and retracts in stock's AlertStore while it holds the pod, so the phone's alert history covers the loan; a kit's alert lookups answer from it. The records go home in the loan's history file at close (the decisions file, renamed); the phone adds each once by sync identifier with its original dates, through a new AlertStore method that starts stock's alert upload. Tests: LoanHistoryTests (new, watch, 4), PodLoanPhoneControllerTests +1, LoanHistoryIntakeTests (new, 1). WatchAppTests 188/188, phone gate 160/160. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the watch holds the pod it writes every device log line to stock's PersistentDeviceLog as DeviceDataManager does, so the phone's device log covers the loan (the throttled text log stays). The lines go home in the loan's history file at close; the phone adds each once, comparing whole lines, with its original time. Tests: LoanHistoryTests +4, LoanHistoryIntakeTests +1, PodLoanPhoneControllerTests +1. WatchAppTests 192/192, phone gate 162/162. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch app signs with $(LOOP_WATCH_ENTITLEMENTS), as the phone signs with $(LOOP_ENTITLEMENTS). It defaults to empty, so with Sport Mode off the watch signs with no entitlements, as stock does, and Browser Build's watch app ID needs no new capabilities. Sport Mode builds set it to the watch entitlements file (HealthKit for the workout session, time-sensitive notifications for wrist alarms) next to SPORT_MODE_ENABLED. Tests: WatchAppTests 192/192 with it set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No cycle after a wrist carb save, a carb delete or an accepted manual bolus; the store observers already refresh the display. On the bench the carb-save cycle auto-bolused for the meal before the manual bolus reached the pod. A G7 reading runs a cycle only when the store kept something new (stock's storedNewGlucose), and the shared 4.2-minute gate is claimed under a lock. Tests: five new watch tests, sabotage-verified; StockRunsTests and G7RelayDedupTests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand
pushed a commit
to kristinkand/Trio
that referenced
this pull request
Oct 18, 2026
… to stop A take-back revoked the watch and took the pump at once. A phone that then couldn't reach the pod left a pump nobody dosed. The phone now takes control and waits up to 5 s for the pod's link; only with it up does it revoke the watch and take over. Without it, it lets go again, nothing changes and the watch keeps running, with a note to bring the phone close. Dosing stays held meanwhile, as the session is not idle. As LoopKit/Loop#2545. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand
pushed a commit
to kristinkand/Trio
that referenced
this pull request
Oct 18, 2026
The watch doses with the settings that came with its grant; a change on the phone during a session never reached it. The screens whose values the grant carries now show "Locked While the Watch Loops" until the hand-back: basal, ISF, carb ratio and target editors, meal, bolus calculator, dynamic ISF, autosens, SMB, target behaviour, advanced algorithm settings and units and limits. Settings › Apple Watch, Nightscout and the rest stay open, so the session can still be ended. As LoopKit/Loop#2545 locks Settings during a loan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand
pushed a commit
to kristinkand/Trio
that referenced
this pull request
Oct 18, 2026
…ne ends it Set again after a hand-over, a temp runs at least Omnipod's 30 minutes, longer than it had left, should the new side not loop: the risky way for a high temp. A temp above the scheduled rate now ends at the hand-over instead, as LoopKit/Loop#2545 does: - Phone to watch: the phone cancels it at the release and hands nothing over; the schedule runs until the watch's first loop. - Watch to phone: the watch leaves it running and reports it with the hand-back; once the phone has read the pod it cancels it, and ends the watch's record of it where it stopped it, final, so IOB and the audit don't count the planned rest. The schedule runs until the phone's loop. Low and zero temps are handed over as before, cancelled and set again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand
pushed a commit
to kristinkand/Trio
that referenced
this pull request
Oct 18, 2026
…tch has the pump On 2026-10-18 the watch went without readings from 09:24 to 10:39: a sensor connect hung while its app was suspended, and only opening it put that right. A phone in reach now wakes it, as LoopKit/Loop#2545 does with every phone cycle: - Phone: 45 s after its own reading, clear of the watch's link to the same sensor, a small application context ("untetheredWake"); at most one per four minutes, as storing the watch's readings fires the same publisher. - Watch: that context re-checks the sensor's acquisition and the alarms and sends the history. A WatchConnectivity wake is now held until what it brought is delivered (at most 20 s, never past the system's expiry), so the callbacks run before the app is suspended again; it was completed on arrival. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand
pushed a commit
to kristinkand/Trio
that referenced
this pull request
Oct 18, 2026
The watch stopped dosing at the hand-back, let go of the pod, and the phone then waited up to 60 s for its last records before it judged the pump. Now, as LoopKit/Loop#2545: - Drain: in the ending phase the watch keeps looping and sends its backlog, paced around the sensor as before, until the phone has confirmed all of it (at most 3 min; the rest goes with the last records). - Last stage: no new loop or bolus (isFinalizing), the temp handled as at any hand-over, the pod read, the pump status recorded, the last records sent and confirmed (at most 30 s), then the pump offered. - The pod is let go only when the phone acknowledges. The phone takes control before it acknowledges, so its connect is standing as the watch lets go, and the history it judges is complete. Without an acknowledgement for a minute the watch lets go anyway and keeps offering, as before; it never takes the pump back on its own. - A relaunch mid-hand-back goes on draining, or stays in the last stage without loops when the pump was offered already. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand
pushed a commit
to kristinkand/Trio
that referenced
this pull request
Oct 18, 2026
…e on its word While it has the pump, the phone keeps a standing grant queued on the watch: the full grant, issued expired, resent after carbs, boluses and adjustments (30 s floor), on any settings change and every 30 min. With the phone out of reach the watch starts from it on an epoch of its own, if the copy is younger than the insulin's action time, and books what the pod delivered past the copy's history as a bolus before its first loop. The phone takes on a newer epoch it hears of before storing its records, lets go of the pod and stops dosing; the watch tells it at once when it is back and asks the wrist once to hand back. As LoopKit/Loop#2545. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand
pushed a commit
to kristinkand/Trio
that referenced
this pull request
Oct 18, 2026
…insulin no record explains Each loop keeps the pod's delivered total; when the next comes ten minutes or more later, on the same pod, the difference is judged against the history like a return's audit, and what no record explains is booked and shown. This catches a watch that started without the phone and then failed. A return starts the check over. As LoopKit/Loop#2545. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r ends it Problem: one unreachable reading of the link to the phone ended a hand-back at once, at the tap and at every resend. The link flaps while either app changes state, so a Hand Back the user chose was dropped while the phone was already acknowledging it, and the prompt came back. - At the tap, an unreachable reading is read again a second later before it refuses, as a Start does; End with the phone truly absent still fails at once. - Once the exchange is under way, a resend that finds the phone unreachable sends nothing and waits for the next; the hand-back holds for its two-minute budget, and an acknowledgement inside it completes it. A live offer is still never queued. - Tests: the fail-fast test runs the re-check; a resend's unreachable reading leaves the hand-back pending, and the next reachable resend sends again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem: control of the pod changed hands on clocks and on the reachability flag, and the rules
were spread over the mechanisms of both apps. A watch back from being off resumed beside a phone
that had taken the pod back; a silent watch kept the pod; the Start and the End trusted a flag
that says whether the other app can take a live message this second, not whether the device is
there; and a message sent before the watch's link to the phone had started up was lost.
- LoanControl.decide (LoopCore, new file): the decisions of the table in the control document as
one function. Inputs: the control state and what the acting device can see. Output: query and
what silence means, take, stand aside, send the stop, refuse, prompt or tell. One case per row,
commented with the row's reason. afterQuery holds the one rule for answers: silence, "I hold
the pod but cannot reach it" and "I hold nothing" all lead to the query's on-silence branch.
Two answer waits: 10 s while the looper waits on screen, 30 s in the background. A watch's
report is overdue after 6 minutes.
- Phone: one place describes the situation, calls the function and carries out the decision.
With the watch's report overdue and the sensor in view, it asks the watch and, with no answer,
takes the pod back and says so ("Watch Not Responding"), in place of the timed "Watch Not
Reporting" warnings. A reading with insulin its records cannot explain asks the watch. A
watch's claim to a loan started before the phone took over on silence gets the stop; a later
one is honoured. An unconfirmed grant is taken back on silence, with one notice.
- Watch: back from being off, it tells the phone it holds the pod and waits for an answer before
the loop has the pod; the stop stands it aside before any dose. End with the phone away waits
for an answer, then is refused on screen with how to stop dosing. Its status report says
whether it reached the pod on its last cycle and when its loan started. The "iPhone Is Back"
prompt is gone: a loan started without the phone continues when the phone returns.
- Watch: loan messages wait until the link to the phone has started up, and messages that arrive
before the Sport Mode stack is ready are held and replayed.
- Tests: LoanControlTests, one per row; the phone asking, taking and standing aside; the watch's
question waiting for the link; End refused and answered; a revived claim refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: with no loan running and the phone dead or left behind, nothing told the looper that nothing was looping: a dead phone fires none of its pre-scheduled Loop Failure notifications, and the watch armed its own only during a loan. - With no loan, every word from the phone pushes out a pre-scheduled, time-sensitive watch notification, "Not Hearing From iPhone", to 25 minutes later, after the phone's own Loop Failure at 20. It fires only if the phone goes quiet, even with the watch app not running. A loan starting removes it. Nothing takes the pod. - The Sport Mode page with no loan shows the phone's loop ring, and "Not hearing from iPhone" in place of "via iPhone" after 25 minutes without a word. - Tests: a word from the phone arms the warning; a loan removes it; the row's case in the control tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: a status query and its answer always went queued, even with the other device reachable, so a watch back from being off could wait out its 30 s with the phone beside it, then loop until the phone's stop arrived. Sent live alone, a message is lost if the sending app goes inactive first, and a lost question reads as silence. - A status query and a status report go by the live path when the other device is reachable, and are queued as well, on the phone and on the watch, as a grant already is. Both sides drop the later duplicate. - Tests: both messages go live and queued; a stop goes by one path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: after a restart the watch showed the loan as running while it asked the phone, and its rebuilt pump manager could reach the pod during the check while the phone was looping it. - The pump manager is built only once the check has passed, when the phone does not answer within the wait. The phone's stop ends the loan before it is ever built. - During the check the Sport Mode page says "Checking with your iPhone…" and "Sport Mode resumes if it doesn't answer.", with no End and no IOB, COB or temp. - Tests: the check's screen state, no pump manager until it ends, and its end on the stop and on silence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: when the pod did not answer within 12 s of a take-back, the phone switched the pod driver to searching for the pod, and nothing on the phone ended that search if the take-back finished another way or gave up. - The take-back's end, verified, closed early or at its 5-minute limit, ends the escalation it started: the control document's transfer rule. - Test: the escalation ends when the take-back verifies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: the rules for which device controls the pod were written only in the code and its tests, and comments pointed to documents outside the repository. - Documentation/SportMode/Control.md: the world Sport Mode is designed for, the principles in priority order and how they resolve conflicts, the vocabulary, the warnings, one row per situation with what each device sees and what happens and why, the transfers, testing artifacts, known limits, and where each decision lives in code. - Code comments point to it, and no longer carry the dates of the incidents behind them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: the document named the transfers as the next part to move into LoanControl without saying whether that was needed. - Section 9 says the transfers stay in their own code on purpose: each is a sequence of steps rather than one decision, every failed step follows the transfer rule, and what differs between them is timing. A different outcome for one transfer would move that choice into LoanControl, with its test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream's new carb entries carry their own sync identifier, and edits use CarbEntryUpdate. Conflicts resolved: - LoopDataManager: the NewCarbEntry conversion stays in LoopAlgorithmSupport, shared with the watch, and upstream's CarbEntryUpdate conversion joins it there. - GlucoseAlertManager: upstream's new silent-mode keys, with the episode-state key left visible to the alert-sharing extension. - The project file. The Sport Mode carb code follows in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: the carb store now takes a new entry's sync identifier from the entry itself, and the separate add-under-an-identifier call that Sport Mode used is gone. - Phone: a carb from the watch's records is created under its journal event's ID, as before, and added the ordinary way; the wrapper that paired an entry with that ID is gone. - Watch: a carb entered during a loan keeps the identity it was composed with as its journal event's ID, so it is stored under the same identity on both devices. - Tests: carb entries carry an identity. The harness test of add-once behaviour is removed: the carb store now guarantees and tests it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tch's own question Problem: say the watch's battery dies during Sport Mode while the phone is at home. Back home, the phone asks the watch, hears nothing, and takes over. When the watch comes back on, it sends the records it saved before it died, and asks whether it still has the pod. The control document's principles decide this case as a judgement call: the phone asked, heard nothing, and has looped since, so it keeps the pod and tells the watch to stop. That needs to know when the watch's Sport Mode started. The question says it; the records didn't, so if they arrived first, the phone could step aside for a watch you had stopped using. - Nothing is logged or sent before Sport Mode starts, so a batch of records says the latest it could have started: its first record, or its send time. The phone judges the records by that, as it judges the question, so the order they arrive in no longer matters. - The control document states the rule. - Tests: records from before the takeover get the stop, with and without a send time. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem: the phone's stop is its answer to the watch's "do I still have the pod?", and it went only by the live path when the watch looked reachable. A live message is lost if the watch app goes inactive first, and a lost stop leaves the watch looping beside the phone until its next records happen to prompt another one. - The stop goes live and queued, like a question and its report. The watch drops the later copy: a stop for Sport Mode it has already stopped changes nothing, where before it tore down twice and sent an extra offer. - Tests: the stop rides both channels; a second copy is dropped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem: a Start, with the phone or without it, gave the watch five minutes to reach the pod. Every takeover seen reached the pod within 25 seconds, most within 4, and a pod not reached in a minute was never reached in five. Meanwhile the looper waited up to five minutes to be told, and for a Start with the phone, the phone had released the pod and held its dosing the whole time. - One shared lease of 60 seconds, for a grant and for an offline start alike, kept in LoanControl beside the other control timings; the phone's lease-end check follows it. - The control document's transfer table says 60 s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem: when the phone takes the pod back, the watch tears down its pod driver and stops being its delegate at once. A command the stop interrupted may still finish and report its dose a moment later; the driver treats a missing delegate as impossible and the app crashes. A Start right after a take-back showed it. - The watch stays the driver's delegate after a teardown; reports from a driver that is no longer the current one are logged and ignored, and any insulin they carry is counted by the phone's take-back check, which books what the records cannot explain. The pod's handle is still saved after the loan, since a release can drop one that never connected. - Test: a driver reporting after its teardown changes nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem: tap Start, then cancel at the "Start without it?" offer, and a grant that arrives afterwards still starts Sport Mode: an idle watch accepted any grant. The request may already have reached the phone when the looper cancelled. - A grant is accepted while a request is outstanding or the offer is still showing; after a cancel it is answered "takeover failed", so the phone takes the pod back at once, and a new Start asks afresh. - The control document's transfer G says so. - Tests: a grant after a cancel is refused and the phone told; a grant while the offer shows is still taken. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem: when the phone asks the watch whether it holds the pod and waits 30 seconds for an answer, nothing cancelled the wait if the loan ended meanwhile. End Sport Mode and Start again within those 30 seconds, and the old question's "no answer" landed on the new Sport Mode: the phone took the pod back from a watch it had just granted, cutting off a bolus in progress. Seen in the Simulator; on a device it needs an End and a Start within half a minute of a silent question. - The question remembers the loan and the phone's role it was asked in; when the wait ends, a verdict for any other loan or role is dropped and logged. - A change of the loan's state clears the open question, so the next loan can be asked at once. - Tests: a question's silence after a hand-back and a new grant takes nothing back; a question can be asked again once the loan changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A pod handed over mid-bolus leaves the record wrong on both sides: the watch's record says the whole bolus with an end in the future, the phone books it as finished, and the insulin check has to correct it afterwards with estimates. A take-back during a watch bolus showed it. The control document now states the rule once, under the transfers: nobody takes the pod while it is giving a bolus. A Start while the phone's own bolus is being given, and an End or a Take Back while the watch's is, are refused with "A bolus is still being given. Try again in N seconds."; the loan is left exactly as it was. A take-over on silence, where nobody can retry, reads the pod first and waits for it to finish the bolus before it sends the stop; its time to reach the pod starts again after the wait. An offline Start cannot ask the phone and takes the pod whatever it is giving; the phone's records are trued up from the pod's delivered total when it is back with the pod, as before. Tests: each refusal, and the take-over on silence waiting out a bolus. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The test for a grant after a failed takeover asked for it the instant the phone had taken the pod back. The phone refuses until the pod has answered, and on a loaded Mac that takes longer than the test allowed; its helper then ended the test process and the suite's result with it. The test now waits for the pod's answer first, like the other settle tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A Take Back during a watch bolus went through, and the insulin check then warned of overstated insulin on board: the check asked the phone's pump driver, which does not count a bolus another device started. It now asks the pod's own status, read just before the stop. The take-over on silence uses the same answer. The tests now model the pod reporting the watch's bolus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An End refused during a bolus was not seen: the bolus progress has the same line on screen and covered the refusal. A refused End now shows why for its 20 seconds, over the bolus it waits for, and the progress comes back after. A Start the phone refuses, and an End the watch refuses during a bolus, now play the watch's failure haptic, so the looper feels the refusal as well as reads it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An End tapped while a watch bolus was still being sent to the pod was accepted, and the hand-back would have finished while the pod was giving the bolus: the rule counted a bolus only once the pod had accepted it, and only at the tap. It now counts from the moment the bolus is sent until it finishes, for a Start, an End and a Take Back alike, and the release checks again, so a bolus started while an End waits for the phone also refuses the End. The rule and its message are stated once. Tests: End and Start while a bolus is being sent, and a bolus started during a pending End. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While an End waited for the phone, each offer carried a watch bolus still being given, and the phone saved it as finished. Loop's dose store does not accept a finished bolus that ends in the future, and stopped the phone app. Loop's own drivers report a bolus as finished only once it is; the phone now does the same with the watch's records. A bolus still being given is acknowledged but kept back, like the running temp the phone already keeps back, and saved once it has finished. Tests: an offer carrying a bolus still being given saves nothing of it, and the next offer after it has finished saves it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sport Mode: the phone lends the Omnipod to the watch. The watch runs Loop itself (its own G7 glucose, its own stores, the identical Loop algorithm) until it hands the pod back. The phone stays the home of the user's data and can take the pod back at any time. It's behind
SPORT_MODE_ENABLED, off by default; with it off, neither app runs any of this..How it works
Safety
The insulin audit. At every hand-back the phone reads the pod's own delivered-insulin total and compares it with what the records say was delivered during the loan (the doses plus the scheduled basal, counted in whole pod pulses). Important to understand that the pod knows how much it delivered but not when it delivered it.
Duplicate carbs. Extra carbs mean extra insulin, so this is the dangerous direction.
Known limitations
-After a force reclaim, the placeholder uploads as an ordinary bolus and is replaced when the watch's records arrive. If the watch returns more than a day later, Nightscout may keep it.
-No migration from earlier Sport Mode builds; update only when no loan is running.
Worth a look
PersistedProperty, for tests.Testing: phone loan tests (164) and watch tests (218) pass. Both apps build with the flag off. A simulated loan runs end to end. Not yet re-tested on a real phone, watch and pod since the hand-off rework.
Needs LoopKit/LoopKit#626, LoopKit/RileyLinkKit#21, LoopKit/G7SensorKit#80 and loopandlearn/OmnipodKit#177; merge those first. Draft.
Built on this PR (drafts)