Conversation
|
Hardware read-out, Gen 3 (BLB_03), iOS 27.0, build The short version: on every connect made while live HR was suspended — 34 of them — the app logged the new line and sent nothing to the daytime-HR feature, and the ring's own (a) App side. (b) Ring side, (c) (d) Morning. Caveat, and why the night is still worth having. The night process stopped at 21:09:36 mid-connect (last line |
|
Reviewed. The state machine is the part that could quietly go wrong here, so I traced it rather than read it, and it holds.
|
…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
|
Thanks for tracing it rather than reading it — the three arm paths / four clears is exactly the inventory I wanted a second pair of eyes on. On the hardware gate: the comment above this one (posted 06:40Z, so it may have crossed your review) is the first read-out, and it is deliberately labelled as half of what the body promised. What it has: 34 suspended connects between 17:39 and 21:09, every one logging the new line with no On Android: agreed, and it is in the body under "Android: driver flag and tests only … nothing clears the flag there yet" — I will sharpen that to say outright that |
…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
|
Overnight half, as promised — Gen 3 (BLB_03), iOS 27.0, build The short version: 18 overnight reconnects inside the sleep window (22:26 → 07:31), the readable ones all logged as suspended connects with no daytime-HR write after them; the ring's own (a) App side. Connects inside the window (one (b) Ring side, (c) (d) Morning. Battery, for what it is worth (n = 1). 76 → 57 % over 22:26 → 07:39 (9.2 h) = 2.06 %/h, linked, 18 auths, on the ring's mid band. The two earlier linked nights in that band on builds with the connect-time triplet read 2.94 and 3.13 %/h; the three unlinked ones 1.90–2.22. First linked night that lands in the unlinked range. Not a controlled pair — the ring had been charged before bed and the builds differ in more than this PR — but it points the same way as the SpO2 result. So, against the gate: |
…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
…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
c5d27c9 to
6497e70
Compare
…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
|
Reviewed at 6497e70. Noting first that you have not asked for this yet: the description ends with hardware owed and "Will report on this PR before asking for review." So this is the code half only; the night is still yours to run. The reasoning is the part I want to single out:
Four of five SpO2 interruptions starting on the exact second of a reconnect is a strong correlation, and you decline to call it the mechanism. Removing the one variable you control, and letting the next night discriminate, is the right move rather than the confident one. The code holds up. I traced The arming site sits inside The case that would otherwise regress is handled explicitly. A live push while suspended sets the flag before Both drivers are exact twins and both default to Three notes, none blocking:
Approving on the code. Ping me when the night is in and I will take it once it is rebased. |
…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
6497e70 to
412d7a9
Compare
|
Rebase done On the hardware: the two read-outs are already on this thread — the suspended-connect half (5710152977, 2026-09-17: 34 suspended connects, On the two shapes (your note 2): they are different because the two values are known at different times, and I would rather that stay visible than fold one into the other. Note 3 stands as written: the Kotlin flag has no setter until #1546; the comment in Ready for review. |
…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
What
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, andstartReengageTimer()'s suspended guard sentdhr_disable/dhr_unsubscribeone second later. The ring logsDHR_mode:3→DHR_mode:0on every overnight reconnect. The Oura app never runs live mode during a sync.This PR adds
OuraDriver.liveHRWanted(defaulttrue, both platforms). Whenfalse, auth success goes straight to.streaming— authenticated and idle, which is all the history drain, SyncTime, battery and status reads key on — and nothing is written to the daytime-HR feature. iOSOuraLiveSourceclears it at the auth-OK step whenliveHRSuspended, logs which path it took (auth OK - live HR suspended (screen off), daytime HR left untouched), and gatesdisableLiveHR()on a newliveHRArmedThisSessionso a suspended connect does not follow the skipped enable with a gratuitousmode 0x00write. 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.kthas no screen-off suspend (#1546), so nothing clears the flag there yet and behaviour is unchanged — the twin is the driver API, not the gate.Why
On a Ring 5 overnight capture from #2075's reporter (2026-09-16,
oura-raw.jsonl), the ring's own SpO2 session (0x6F, 1 Hz) was interrupted five times inside its bedtime window, for 32 / 49 / 3 / 6 / 31 min ≈ 2 h of a 9 h night. Four of the five interruptions start on the exact second of a NOOP reconnect (the ring's0x76 bedtime_periodrecord stamps the connect second; its0x43log printsSpO2_HFM;0at both ends of each gap). Whether the ring is reacting to the daytime-HR flick or to the connection itself cannot be separated from that capture — every visit had both — so this change removes the one we control and makes the next night the measurement.Verification
OuraProtocol:testAuthSuccessSkipsLiveHRTripletWhenNotWanted(no command on auth success,.streaming, a stray enable ACK is inert, the history fetch runs from there) andtestLiveHRWantedDefaultsToArmingTheTriplet(default path byte-for-byte the existingtestFullEnableSequence).swift test: 55/55.OuraDriverTest—./gradlew testFullDebugUnitTest --tests com.noop.oura.OuraDriverTest: 51/51, forced re-execution.Strand(macOS) andNOOPiOS(generic/platform=iOS), bothBUILD SUCCEEDED.doc_comment_lintclean.DHR_mode:30/34 in the ring's0x43log) and the overnight half (2026-09-18, 18 overnight reconnects, 0 triplets). A third night on the rebased tree(2026-09-20/21) behaved the same: 51 suspended overnight connects, 0 daytime-HR writes.
Refs #2075