Repository navigation
SportMode - Nightscout uploads from the watch during a loan - #2554
Draft
jeremybarnum wants to merge 135 commits into
Draft
jeremybarnum wants to merge 135 commits into
jeremybarnum wants to merge 135 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>
If Sport Mode is started on the watch away from the phone and the watch then dies, the phone is never told about that loan. When the pod comes back into range, the phone simply resumes control of it, with no record of what the watch delivered, and doses as if that insulin had never been given. Now, when the phone reads the pod again after 10 minutes or more without it, on a phone set up for Sport Mode, it compares the pod's total with its own records, as it does after a forced take-back. Extra insulin is booked as a bolus, the user is told, and dosing continues. The watch's records replace the estimate when they arrive; records that don't cover the gap leave it in place. The 10 minutes keep ordinary brief separations, such as a missed reading or the phone in another room, from running the check. Without the watch involved, the pod delivers only what the phone's records and schedule explain, so a brief separation would never book anything, especially given the 0.20 U margin. The check could act on every reading instead, but that is more than this corner case needs. The notices for these estimates are shortened to fit a banner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The phone keeps one estimate of insulin it couldn't account for, and a returning watch's records remove it only if they cover the gap it was booked for. An estimate whose gap is never covered stayed for good, and blocked the booking of any later gap. Once it is older than insulin's action time it no longer counts in insulin on board, so a new gap now replaces it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When Sport Mode is started and the phone looks out of reach, the watch now offers the offline start at once. If the phone goes out of reach while the watch is waiting for it, the offer appears at that moment. Otherwise it appears after 10 seconds without an answer. The request to the phone still goes out, and if the phone answers before the user confirms, Sport Mode starts the normal way. Before, the watch always waited for the phone first: 8 seconds when it already saw the phone as out of reach, and 60 seconds when it did not. That included the common case of a phone left behind moments earlier, which the watch had not yet noticed. The offline start is treated as a backup with a somewhat higher risk, not a last resort: if the phone was in fact nearby, the watch tells it about the loan as soon as they are in contact and the user is asked to keep it or hand it back. Right after the watch app opens, before its link to the phone is set up, the phone can look out of reach even when it is nearby. A Start tapped in that moment does not get the offer at once; it waits the 10 seconds, so a phone that is actually there has time to answer and no false offer appears. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… them the stock way The watch marked every carb copied from the phone as another app's, so the stock delete refused them all and a separate delete that skipped the check was used. The phone now sends each entry's own flag: on the watch, Loop's entries can be deleted with the stock call and another app's cannot, as on the phone, and the watch offers Delete only on Loop's entries. An older phone that sends no flag leaves its entries protected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the watch app restarts during a loan, after a dead battery, a reboot or watchOS relaunching it, it resumes the loan. If the phone had the pod in the meantime, the watch did not know what the phone delivered and dosed as if nothing had been given. Now, before its first cycle, it reads the pod and compares the total with its last reading, its own records and the schedule; insulin they cannot explain is booked as a bolus, as the phone does after a gap. If the watch was off for longer than insulin's action time, it no longer resumes. What was given meanwhile cannot be timed, so its records go to the phone as for a loan that has ended, and the user is told Sport Mode ended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A glucose reading arriving right after a relaunch could start a loop cycle before the watch had counted the insulin given while it was off. The loop now gets the pod only once that check has finished or been skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hand-back can arrive without a pod reading, when the watch lets the pod go before the phone's acknowledgement lands. No audit is queued then, and the temp cancel ran only inside the audit, so the watch's temp ran on to its end outside the phone's records, and the phone's next reading booked the difference as insulin from an offline start. The cancel now runs on every verified return, audited or not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A loan starting or ending replaced the pending refresh list with a full refresh, dropping any size change waiting in it, so the screen could keep its landscape layout, without its header, while upright. The full refresh is now added to the list instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`bookUnattributedInsulinOnForceReclaim` was a constant `true`; its other branch never ran. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The pump manager is built only to read the pod's last total, and it starts connecting to the pod when it is built. The watch now releases it before dropping it, as at the end of a loan, so it does not dial a pod the phone has taken back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The phone compared the pod's total with its records in six places, each with its own starting reading, source of records, estimate name and retire rule. Where two of them met, insulin could be counted twice, or one device's doses judged against the other's records. - One reading starts the open window, written whenever the pod changes hands: the grant, the watch's takeover reading, the return, and each reading while the phone holds the pod. The latest reading always wins, so the phone's own doses after the watch took over stay the phone's. The check after a hand-back or take-back closes the watch's window; the per-cycle check never does. - One list of estimates, each named by its window and retired by late records that fall in it. A second estimate stands beside the first; one past insulin's action time leaves the list, and its dose stays. An estimate is never counted in a window's records. - The offer carries when the watch took its takeover reading, so an adopted loan's window starts there unless the phone read the pod later. - On the watch, the takeover and resume checks share one function and one estimate name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
During a loan started away from the phone, the prompt waited for the phone to stay reachable for 30 s. From the watch's side reachability drops whenever its app leaves the screen, so in use the prompt never appeared. It now rises when the phone becomes reachable or any message from it arrives, and is asked once per loan: Keep is the answer for the rest of it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A loan the watch starts alone is numbered from the watch's own count, so after one that failed at the takeover the phone's next grants carried numbers the watch had already seen and refused; the pod stayed released until the phone's 10-minute dead-man ran out. The Start request now carries the highest number the watch knows, and the phone grants above it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…fore the offline offer - A grant refused as already seen is answered as a failed takeover, so the phone takes its pod back at once instead of holding it released until its dead-man runs out. - Just after the app comes on screen, reachability can read false with the phone there. An unreachable read at the tap is read again a second later; with the phone there after all, the request waits the normal 10 s for its grant. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The hold began at the release, so between the grant's temp cancel and the release the phone's own loop could still dose. A temp set in that second went unacknowledged once the pod was released, which put the app into its uncertain-delivery state and had the pod driver dial the lent pod during the watch's takeover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # LoopTests/PodLoanPhoneControllerTests.swift
The phone acted on insulin its records could not explain only after 10 minutes without reading the pod. A loan started away from the phone can be shorter: the phone came back after a few minutes, saw the watch's insulin, and kept looping without counting it. On a phone set up for Sport Mode, every reading is now acted on above the same band; a clean reading is logged as before and raises nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt completes Problem: the watch checked the pod's total against its records once at launch, and let the loop dose when that one read failed; the phone checked at every reading but after the cycle, so the cycle that first read the pod after a gap decided without the estimate. Either way insulin the pod had delivered could be left out of a dose decision. - Both apps: the check runs inside the pump manager's report of a reading, after the events are stored and before the report completes. The kit finishes the read only then, so the cycle the reading feeds always sees the verdict. - Phone: `checkOwnPumpTotal` takes a completion and is called from the pump report instead of the loop-update pulse; one hook line in DeviceDataManager's delegate method. - Watch: the controller keeps the ledger's open window (`ledgerLast`) in its persisted state, opened at a grant from the copy's reading and moved by every report. The window's records come from the watch's own dose store, as on the phone. The takeover read and the first read after a relaunch are ordinary reports; the separate takeover and resume checks, the cached copy total and records, and the resume's reconciliation seed are gone. After a relaunch nothing doses before the pod reports: Loop's pump-data recency check refuses until then. - `LoanDoseRecord.init(storeDose:)` in LoopCore: the one mapping from a store's dose to a loan record, used by the grant's history and both ledgers. - Tests: the phone's report completes only once the estimate is in the store; the watch's copy tests drive a reading through the window; a reading after a relaunch closes the window from the last reading before it; nothing doses after a relaunch before the pod reports. 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>
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: Nightscout uploads from the watch during a loan. Stacked on #2545; only the last two commits are this PR (net diff). From my fork so you can push edits. Needs LoopKit's service configuration sharing (LoopKit PR linked below) and NightscoutService's watchOS build (linked below).
In #2545, Nightscout shows no loop status during a loan and the window fills in after hand-back. With this, the watch uploads glucose, treatments and loop status itself while it holds the pod, including with the phone off or out of reach (the watch uses its own Wi-Fi or cellular; see the cellular limit below).
How it works
RemoteDataServicesManager, compiled into the watch target unchanged, with the same store triggers stock'sDeviceDataManageruses. New code is one file,LoanRemoteUploads.swift.DeviceConfigurationSharing, the same protocol the pump and CGM use). Start grants only, never the standing copy, never logged. The watch adopts the ones it knows and drops them when the pump is torn down at the loan's end.DeviceDataManager). Nightscout keeps one reading per timestamp to the second and type, and entries carry no id; each device times a G7 reading from its own receipt, so a phone copy and a watch copy that straddle a second boundary become two rows. I checked this against a real site: identical timestamps → 1 row, 0.5 s apart within a second → 1 row, 0.5 s apart across a second → 2 rows. The watch follows its CGM's upload setting, else the phone CGM's (the simulator's "Upload CGM Samples"), carried in the grant.Bench-tested with a real pod (not worn) and a G7: uploads with the phone off, a force-quit and relaunch mid-loan, then hand-back with the phone skipping exactly what the watch confirmed (no duplicate glucose or loop status records).
Worth a look
DeviceDataManager.swift(one guard line) andRemoteDataServicesManager.swift(itsUserDefaultsanchor helpers losefileprivate, so the phone can move a bookmark at hand-back).LoanMessages,LoanTransport,PodLoanPhoneController+Grant/+Wiring); watch uploads (LoanRemoteUploads,StockLoopSession,WatchApp/Info.plist'sAppGroupIdentifierfor the anchors); exclusivity and skipping (DeviceDataManager+PodLoan,PodLoanPhoneController+Records,PodLoanWatchController+Handback); relaunch (PodLoanWatchController+State/+Start/+Resume).Related PRs (Sport Mode watch uploads)
🤖 Generated with Claude Code