feat(oura): Experimental "All-day heart rate & HRV" — stand the daytime-HR hold down for the learned night, not for every screen-off - #2300
Conversation
|
First hardware read-out, and one more commit. e745474 — the enabling auth line now names the policy too. With the toggle on, a connect that arms daytime HR used to print the pre-existing Afternoon on the Gen 3 ring (iPhone, 2026-09-17, toggle ON, phone pocketed, NOOP not opened): 13:59–16:11, 449 Still owed before I ask for review: the DB count (daytime 5-min bins back above 120/144 for a full 09–18 h day) and tonight's suite / SpO2 unchanged against the 09-16/17 control night — the toggle must not re-create the overnight hold. |
e745474 to
8d26f8f
Compare
|
The conficts is related to localization. I'll be fixing it whent this PR is reviewed. As othewise I would have to fix the localization every time other PRs touching it would be merged! |
…uspended Every Oura connect ran the driver's live-HR enable triplet (dhr_read / dhr_enable / dhr_subscribe) regardless of the app's screen-off suspend, and `startReengageTimer()`'s suspended guard then sent dhr_disable / dhr_unsubscribe one second later. The ring logged `DHR_mode:3` -> `DHR_mode:0` on every overnight reconnect. On a Ring 5 overnight capture (issue ryanbr#2075's reporter, 2026-09-16) four of the five interruptions of the ring's own SpO2 session began on the exact second of such a reconnect (3-49 min each, about 2 h of a 9 h night); the Oura app never runs live mode during a sync either. `OuraDriver.liveHRWanted` (default true, both platforms): when false, auth success goes straight to `.streaming` - authenticated and idle, which is all the history drain, SyncTime and status reads key on - and nothing is written to the daytime-HR feature. iOS `OuraLiveSource` clears it at the auth-OK step when `liveHRSuspended`, logs which path it took, and gates `disableLiveHR()` on a new `liveHRArmedThisSession` so a suspended connect does not follow the skipped enable with a gratuitous `mode 0x00` write. A live push while suspended marks the session armed first, so the existing self-heal still sends the disable. Morning resume is unchanged: `reengageLiveHR()` arms the stream without the triplet's read. Android: driver flag and tests only. `OuraLiveSource.kt` has no screen-off suspend (ryanbr#1546), so nothing clears the flag there yet and behaviour is unchanged. Tests: OuraProtocol `testAuthSuccessSkipsLiveHRTripletWhenNotWanted` / `testLiveHRWantedDefaultsToArmingTheTriplet` and their Kotlin twins (swift test 55/55, gradle OuraDriverTest 51/51). App targets: `Strand` (macOS) and `NOOPiOS` built locally. Hardware read-out owed before merge: one suspended night on a Gen 3 - `DHR_mode:3` per overnight reconnect (1 -> 0), `0x6F` gap count inside the sleep window, morning resume. Refs ryanbr#2075 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KCBfMBAJzLsefWjb6dLSeL
…me-HR hold down for the learned night, not for every screen-off An Oura ring emits daytime heart rate only while a client holds it in daytime-HR mode (`DHR_mode:3`); there is no banked daytime HR family it produces on its own. The screen-off suspend (ryanbr#1526) stops holding the ring so its sleep suite can run — the right call at night (r = −0.93 between the overnight hold and the ring producing SpO2 / a hypnogram / `0x6A`) — but its gate is the screen, which is also dark for most of a working day. Measured on the Gen 3 ring's own DB: daytime 5-min HR bins (09–20 h, of 144) read 123–144 every day up to the night that build shipped and a median of ~16 every day since; on 2026-09-16 the 10–14 h hours held 0 samples and every daytime beat was `srcChannel=1` (the `0x80` the ring only emits while held). Windowed rMSSD by day empties for the same reason — the daytime beats are the same records. The 300 s drain is not at fault: the night (channels 2+3) is complete. This adds a default-OFF Experimental toggle, "All-day heart rate & HRV". OFF is today's rule, byte for byte. ON keys the stand-down on the NIGHT instead of on the screen: outside the learned night band a dark screen no longer suspends, so the 15 s re-engage keeps the ring in daytime mode and the ring banks `0x80` for the drain — exactly the pre-ryanbr#1526 daytime behaviour; inside the band the screen-off grace applies unchanged, so the merged night fix is untouched. The trade (the ring's own daytime PPG costs charge) is the user's, which is why this is a toggle and not a new default. The band is `NightStandDown` (StrandAnalytics, pure): bedtime = habitual midsleep − half the typical night, wake = + half, exactly `BatteryEstimator.bedtimeAlert`'s derivation so the two policies share one notion of the user's night, opened 1 h before that bedtime and closed 1 h after that wake. Cold start (fewer nights than the learner needs) yields nil and the screen rule applies — no invented 23:00 clock for the shift/late sleepers the learner exists for. The app layer supplies it from the same hourly midsleep cache the battery night-guard already keeps. `OuraLiveSource.shouldSuspendLiveHR` gains an `allDay` argument (`.off` = the old predicate exactly); `liveHRSuspended`, the suspend, the resume and ryanbr#2275's `liveHRWanted` at auth all keep reading that one predicate, so no two gates can disagree. New: `resumeAfterStandDownIfReleased`, checked from the 300 s history fetch — the only tick that still runs while suspended — re-arms the hold when the band closes with the screen still dark (the screen-on path already re-arms). The suspend / auth log lines name the band ("inside the night stand-down 21:30–07:30 (all-day HR on)") or the cold-start reason. Android: no twin — `OuraLiveSource.kt` has no screen-off suspend (ryanbr#1546), so its daytime HR is already whatever the process lifetime gives; the toggle would gate nothing there. Settings copy lands in de/es/fr/pt-PT + it/pl/ru/zh-Hans/zh-Hant so the i18n ratchets stay where they are. Stacked on ryanbr#2275 (`c5d27c9de`) — it reuses that PR's auth-time `liveHRWanted`. Tests: `NightStandDownTests` (6, `swift test`: band derivation, circular contains, a daytime sleeper, cold start, whole-day guard, parity with the battery night-guard); `OuraLiveHRSuspendPolicyTests` +5 (toggle off = old rule, on outside/inside the band, grace inside the band, cold start keeps the screen rule, band = learned schedule), 14/14 under `xcodebuild test`. StrandAnalytics 2046/0. `Strand` (macOS) and `NOOPiOS` BUILD SUCCEEDED; `doc_comment_lint` OK; `i18n_audit --ci` unchanged. Hardware: owed — one weekday with the phone pocketed and NOOP not opened 09–18 h (daytime bins back above 120/144) AND the following night's suite / SpO2 unchanged against the 09-16/17 control night (the fix must not re-create the hold). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCUoRfjiQHTdb5G5bPs8rW
…only on the suspended one
With the "All-day heart rate & HRV" toggle on, a connect that arms daytime HR
logged the pre-item-27 text ("auth OK - enabling live HR") and nothing said
which policy had decided. The first daytime read-out (2026-09-17 16:11) had to
infer "toggle on, outside the band" from two hours without a SUSPENDED line and
the 5 min screen-off grace — the toggle's state was in no line, so a screen that
simply never went dark would have produced the same log.
The enabling line now carries the policy when the toggle is on: the learned band
and whether the clock is inside or outside it, or "no learned sleep schedule
yet - screen rule applies" at cold start. With the toggle off the line is
unchanged byte for byte. The suspended line already named the band; both now
come from one switch.
macOS Strand target: BUILD SUCCEEDED. No test pins the string.
Refs ryanbr#1526
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GH5KCTfNkrVb4i5aUwNcP2
8d26f8f to
89d7cfe
Compare
|
@ryanbr this is just rebased with conflict solving on the localization. reday for review |
Stacked on #2275 (reuses its auth-time
liveHRWanted). Refs #1526.What
A default-OFF Experimental toggle, All-day heart rate & HRV, for an active Oura ring. OFF is today's rule, byte for byte. ON keys the live-HR stand-down on the user's night instead of on the screen: outside the learned night band a dark screen no longer suspends the 15 s re-engage, so the ring stays in daytime-HR mode and banks
0x80for the 300 s drain — the pre-#1526 daytime behaviour; inside the band the screen-off grace applies unchanged, so the night fix is untouched.NightStandDown(StrandAnalytics, pure): bedtime = habitual midsleep − half the typical night, wake = + half — the same derivationBatteryEstimator.bedtimeAlertuses, so the two policies share one notion of the user's night — opened 1 h before that bedtime, closed 1 h after that wake. Cold start (fewer nights than the learner needs) yields nil and the screen rule applies: no invented clock for the shift/late sleepers the learner exists for.OuraLiveSource.shouldSuspendLiveHRgains anallDayargument (.off= the old predicate exactly).liveHRSuspended, the suspend, the resume and fix(oura): do not arm daytime HR on a connect made while live HR is suspended #2275'sliveHRWantedat auth all keep reading that one predicate, so no two gates can disagree.resumeAfterStandDownIfReleased, checked from the 300 s history fetch — the only tick that still runs while suspended — re-arms the hold when the band closes with the screen still dark; the screen-on path already re-arms. The suspend / auth lines name the band:SUSPENDED - screen off 5 min inside the night stand-down 21:30–07:30 (all-day HR on), or the cold-start reason.AppModel.ouraAllDayLiveHRKey+ the band from the hourly midsleep cache the battery night-guard already keeps; Settings card under Experimental, Oura-only; copy in de/es/fr/pt-PT + it/pl/ru/zh-Hans/zh-Hant.Android: no twin.
OuraLiveSource.kthas no screen-off suspend (#1546), so its daytime HR is already whatever the process lifetime gives; the toggle would gate nothing there. Happy to add the setting + a no-op if you would rather the two Settings screens match.Why
The ring emits daytime heart rate only while a client holds it in daytime-HR mode; there is no banked daytime HR family it produces on its own. #1526's screen-off suspend stops holding the ring so its sleep suite can run — right at night (r = −0.93 between the overnight hold and the ring producing SpO2 / a hypnogram /
0x6A; the 2026-09-16/17 night with nobody on the link ran 14 uninterruptedCVA_statecycles) — but a dark screen is also most of a working day.Measured on the Gen 3 ring's own DB (
rrInterval,deviceId LIKE 'oura%',tsSuspectexcluded), daytime 5-min bins covered 09:00–20:00 local, of 144: 123–144 every day 07-29 → 08-17, 2–60 (median ~16) every day since 08-18 — the night the suspend build went on the phone. On 09-16,hrSampleper hour: 10–14 h 0, 18 h 0, 20 h 0; every daytime beatsrcChannel = 1(the0x80the ring only emits while held). The 15–17 h block is when NOOP was in the foreground. The 300 s drain is not at fault: the night (channels 2 + 3) is complete. Windowed rMSSD by day is empty for the same reason — the daytime beats are the same records — hence "& HRV" in the name.The trade is ring battery (its own daytime PPG; the pre-08-17 nights ran ~2.9 %/h held vs ~0.9–1.1 %/h left alone, the daytime cost is unmeasured), which is why this is the user's toggle and not a new default.
Verification
NightStandDownTests(6,swift test): band derivation, circular contains across midnight, a daytime sleeper's band that does not cross midnight, cold start, the whole-day guard, parity with the battery night-guard's bedtime.OuraLiveHRSuspendPolicyTests+5 (14/14,xcodebuild test): toggle off = old rule; on, outside the band never suspends; on, inside the band suspends after the grace and the grace still holds; cold start keeps the screen rule; the band handed to the policy is the learner's.Strand(macOS) andNOOPiOSBUILD SUCCEEDED (CI does not build the app targets).doc_comment_lintOK;i18n_audit --ciFAIL set unchanged from the base.