Skip to content

fix(oura): do not arm daytime HR on a connect made while live HR is suspended - #2275

Open
pipiche38 wants to merge 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-no-dhr-triplet-while-suspended
Open

pipiche38 wants to merge 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-no-dhr-triplet-while-suspended

Conversation

@pipiche38

@pipiche38 pipiche38 commented Sep 16, 2026

Copy link
Copy Markdown

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, and startReengageTimer()'s suspended guard sent dhr_disable / dhr_unsubscribe one second later. The ring logs DHR_mode:3DHR_mode:0 on every overnight reconnect. The Oura app never runs live mode during a sync.

This PR adds OuraDriver.liveHRWanted (default true, both platforms). When false, 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. iOS OuraLiveSource clears it at the auth-OK step when liveHRSuspended, logs which path it took (auth OK - live HR suspended (screen off), daytime HR left untouched), 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 (#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's 0x76 bedtime_period record stamps the connect second; its 0x43 log prints SpO2_HFM;0 at 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) and testLiveHRWantedDefaultsToArmingTheTriplet (default path byte-for-byte the existing testFullEnableSequence). swift test: 55/55.
  • Kotlin twins in OuraDriverTest./gradlew testFullDebugUnitTest --tests com.noop.oura.OuraDriverTest: 51/51, forced re-execution.
  • App targets built locally (CI does not): Strand (macOS) and NOOPiOS (generic/platform=iOS), both BUILD SUCCEEDED. doc_comment_lint clean.
  • Hardware: two read-outs on this thread — the suspended-connect half (2026-09-17, 34 suspended connects, DHR_mode:3 0/34 in the ring's 0x43 log) 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

@pipiche38
pipiche38 marked this pull request as ready for review September 16, 2026 15:08
@pipiche38

Copy link
Copy Markdown
Author

Hardware read-out, Gen 3 (BLB_03), iOS 27.0, build integration/oura-full@2fa8e0e0 = upstream/main@8576a2dd0 + this PR (c5d27c9de) + the rest of the integration branch. Capture 2026-09-16 17:39 → 2026-09-17 07:59, report.txt line 4 confirms the 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 0x43 log on those same seconds holds only check_sleep: DHR_mode:3 per suspended connect went from 1 to 0. The pre-fix pattern is visible in the same sidecar twenty minutes earlier, so the A and the B are on one ring, one evening, one clock.

(a) App side. auth OK - live HR suspended (screen off), daytime HR left untouched ×34 between 17:39:56 and 21:09:36 (links dropped on supervision timeout every 1–14 min that evening, so the sample is larger than a normal night's), plus one at 07:58:43 on a background relaunch. -> dhr_enable following any of them: 0. The only -> dhr_disable of the evening is 17:40:03 ×4 — the self-heal on a ring still streaming from a 17:23 screen-on session (see (d)), not a connect-time write.

(b) Ring side, 0x43 on the same seconds. Each of the 34 suspended connects wrote exactly one line, check_sleep (17:43:37, 17:48:23, 17:53:37, … 21:09:16, 21:09:36). DHR_mode:3 on a suspended-connect second: 0 / 34. The four connects on the previous build in the same sidecar (17:03:35, 17:04:50, 17:05:07, 17:19:21 — 65fb2ec0, before the flash) each wrote DHR_state:4 → DHR_state:2 → DHR_mode:3 → DHR data sub → DHR_state:0 → DHR_mode:0 → DHR unsub → check_sleep: the triplet, then the disable a second later, exactly the sequence this PR removes.

(c) 0x6F SpO2 inside the sleep window (21:40 → 07:35). 2,671 records at a 13-s median cadence, one gap > 2 min: 01:35:02 → 01:39:27 (4.4 min). It does not start on a reconnect second, because there were none: the app process was not running from 21:09:36 to 07:41:11 (see the caveat below), so this night is a zero-client control rather than the test the body describes. The one gap coincides with the ring's own CVA_state;0 at 01:35:03 → ;1 at 01:38:02 — the ring pausing its suite at a wake (both the Oura app's and NOOP's hypnograms show awake there). So a gap can occur with no client on the link at all, and when it does it lines up with the ring's state machine. The Ring 5 night's "4 of 5 gaps start on a reconnect second" still stands as the thing to measure; tonight says only that the baseline gap rate with nobody connected is ~1 per night on this ring.

(d) Morning. 07:58:44 live-HR re-engage RESUMED - screen ondhr_enable / dhr_subscribe07:59:14 receiving live data - first HRring WORN - live HR streaming. Earlier, in the previous process's clipped head, the ring logged DHR_state:4 → DHR_mode:3 at 07:43:45 (the first screen-on re-engage after opening the app) and DHR_mode:3 every 15 s from 07:53:24 (the re-engage tick). live-HR push arrived while SUSPENDED … self-healing now: 2 in the log (17:39:56 and 07:58:37, both background relaunches with the ring still streaming from the previous process); the ring answered each with DHR_mode:0 + DHR unsub within 1–2 s. Self-heal armed and effective; nothing in the new gate got in its way.

Caveat, and why the night is still worth having. The night process stopped at 21:09:36 mid-connect (last line -> get_serial, last battery row 21:09:36) and nothing relaunched it until the app was opened at 07:41 — the ring spent the night alone. The ring's 0x43 log covers those hours on its own clock: 14 uninterrupted CVA_state 2→3→4→5→1 cycles from 21:43 to 07:08, zero DHR lines, and it banked the whole night (hypnogram 1,196 codes in one write at 07:41:30, 0x49 window 21:40:29 → 07:35:29 = the Oura app's 21:40 → 07:35). So the overnight-reconnect count is 0/0 rather than n/n, and the evening's 34 suspended reconnects are the sample. The next overnight with reconnects will give the (c) number this PR was built to measure; the fix itself is doing exactly what it says on every suspended connect we have.

@ryanbr

ryanbr commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Reviewed. The state machine is the part that could quietly go wrong here, so I traced it rather than read it, and it holds.

liveHRArmedThisSession is complete

Gating disableLiveHR() on "did we actually arm it" is only safe if EVERY path that puts the ring into daytime-HR mode sets the flag. Miss one and the ring is left streaming with no disable, which is the precise interference this PR exists to remove, arriving by a different door.

All three arm paths set it:

  • :1697 the driver's triplet completed (if driver.liveHRWanted)
  • :1965 a live push arrived, the ring is evidently streaming whatever we asked
  • :2429 reengageLiveHR() wrote an enable

And every exit clears it: :2406 after a disable is sent, :2596 on a new driver/session, :2711 on teardown, plus the reset at :1563.

The suspended-connect path at :2421 calls disableLiveHR() and returns before the re-engage write, so on a connect that never armed, the disable is correctly a no-op rather than a mode 0x00 write. That is the behaviour the PR claims, reached the way it claims.

The framing is right

Whether the ring is reacting to the daytime-HR flick or to the connection itself cannot be separated from that capture, so this change removes the one we control and makes the next night the measurement.

That is the honest shape for this kind of change, and it is worth saying explicitly because the evidence is strong enough that it would have been easy to overclaim. Four of five SpO2 interruptions landing on the exact second of a connect is a striking correlation, and it still does not isolate the cause. Removing the controllable variable and measuring again is the right move.

Two notes

The Android flag is unused production code until #1546. liveHRWanted exists on the Kotlin driver with test twins, but nothing clears it because OuraLiveSource.kt has no screen-off suspend. I think that is defensible, since the driver is a shared protocol implementation and keeping its API identical across platforms is the point, but it is worth naming in the PR body so a later reader does not find a flag nothing sets and assume it was forgotten.

Nothing downstream appears to need the triplet. testAuthSuccessSkipsLiveHRTripletWhenNotWanted covering "the history fetch runs from .streaming" is the assertion that matters, since .streaming meaning "authenticated and idle" is the load-bearing claim of the whole change.

Status

Not suggesting this lands yet, and that matches your own note: the suspended night on hardware is owed, with a clear read-out planned (DHR_mode transitions per reconnect, 0x6F gap count inside the sleep window against previous nights, morning resume streaming). That is the right gate, since the entire justification is a behaviour on a device none of us can simulate.

Ping when the night is in and I will look again.

pipiche38 added a commit to pipiche38/noop that referenced this pull request Sep 17, 2026
…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
@pipiche38

Copy link
Copy Markdown
Author

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 dhr_enable after it, and the ring's own 0x43 log on those same seconds holding only check_sleepDHR_mode:3 0/34 — with the pre-fix DHR_state:4 → DHR_mode:3 → … → DHR unsub sequence visible in the same sidecar twenty minutes earlier on the previous build. What it does not have: the overnight reconnects, because the app process stopped at 21:09 and nothing relaunched it until 07:41, so the ring spent the night alone. That makes the 0x6F half a zero-client control (one 4.4-min gap at a wake, on the ring's own CVA_state;0) rather than the per-reconnect measurement your gate asks for. So I agree this does not land yet. The next linked night runs on this change; I will ping with the reconnect-by-reconnect DHR_mode count and the 0x6F gaps against the 09-15/16 and 09-16/17 nights.

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 liveHRWanted is a flag nothing sets on Android until #1546 gives OuraLiveSource.kt a suspend path, so nobody reads it as forgotten.

pipiche38 added a commit to pipiche38/noop that referenced this pull request Sep 17, 2026
…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
@pipiche38

Copy link
Copy Markdown
Author

Overnight half, as promised — Gen 3 (BLB_03), iOS 27.0, build integration/oura-full@cb4f3b64 = upstream/main@1f37c4e2a + this PR (c5d27c9de) + the rest of the integration branch (#2300 stacked on it, all-day HR toggle ON). Capture 2026-09-17 19:44 → 2026-09-18 07:45, report.txt line 4 confirms the build. This time the app process stayed alive from 19:44:58 to the export (no relaunch, no kill), so the night has the reconnects the previous one lacked.

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 0x43 log on the 16 connect seconds it covers holds check_sleep only, DHR_mode:3 0 / 16; the ring ran its night suite uninterrupted across them; and the 0x6F SpO2 stream has no gap at all — 0 gaps > 2 min, 0 gaps > 1 min — against one 4.2-min gap on the previous night with nobody connected, and 4 of 5 gaps starting on a reconnect second on the Ring 5 night that motivated the change.

(a) App side. Connects inside the window (one get_battery row per auth in the DB): 22:25:49, 23:04:12, 00:03:55, 00:37:06, 00:38:43, 01:35:07, 01:36:56, 01:51:32, 02:37:40, 03:22:59, 03:41:07, 03:45:52, 03:47:51, 03:51:10, 04:34:12, 06:08:37, 06:08:57, 06:43:50 — 18. The strap log's kept window starts 04:34:11 (the in-memory log is capped at 5,000 lines and the 5-minute background re-scores of #2296 fill it in about 3.5 h — a separate thing), so the lines are readable for the last seven: auth OK - live HR suspended (screen off, night stand-down …), daytime HR left untouched ×7 (04:34:12, 06:08:37, 06:08:57, 06:43:48, 07:39:08, 07:41:34, 07:41:48). -> dhr_enable after any of them: 0 — the first dhr_enable of the night is 07:43:44, the live-HR re-engage RESUMED - screen on. -> dhr_disable: 0. live-HR push arrived while SUSPENDED: 0.

(b) Ring side, 0x43 on the same seconds. The sidecar holds the ring log from 01:23 to 07:44, which covers 13 of the overnight connects (01:35:07 → 06:43:50) plus the three at the wake (07:39:09, 07:41:35, 07:41:49). On every one of those 16 seconds the ring wrote blestda and check_sleep … and nothing else. DHR_mode:3 on a suspended-connect second: 0 / 16. The whole 01:23 → 07:43 span holds zero DHR lines of any kind; the first is DHR_mode:3 at 07:43:43 (ring clock), which is the app's 07:43:44 screen-on dhr_enable landing, followed by one per 15-s re-engage tick. Around the reconnects the ring's own suite ran untouched: CVA_state 2→3→4→5→1 cycles at 01:54, 02:35, 03:16, 03:57, 04:39, 05:21, 06:02, 06:43, 07:24 (the 41-min spacing this ring keeps), and CVA_state;0 at 07:31 when it ended its night on its own.

(c) 0x6F SpO2 inside the sleep window. 8,197 records from 22:39:17 to 07:31:00 at a 3-s median cadence (the SpO2 sidecar is complete for the night, unlike the raw one). Gaps > 2 min: 0. Gaps > 1 min: 0. None of the 18 reconnects started a hole, and the ring never paused the stream at a wake either. For scale, the same sidecar's previous night (09-16/17, zero clients on the link) has exactly the 4.2-min gap at 01:35 reported above, so the method sees a gap when there is one. 0x6A sleep_period is likewise whole: 1,079 records 22:30 → 07:30 at 30 s, no gap.

(d) Morning. 07:43:44 live-HR re-engage RESUMED - screen ondhr_enable / dhr_subscribe → ring DHR_mode:3 + DHR data sub at 07:43:43 (ring clock, ~1 s skew), then DHR_mode:3 every 15 s. No HR followed because the ring had been put on its charger at 07:41 (ring NOT WORN - on charger, BQ;CE in the ring log, charging=1 in the battery rows from 07:41:35), so "streaming" is not demonstrable on this morning — the write landing on the ring is. The self-heal path had nothing to do: 0 pushes while suspended, 0 disables.

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: DHR_mode per overnight reconnect 0/16 on the ring and 0/7 in the readable log (the five connects before 01:23 are covered only by the SpO2 stream, which has no hole at any of them), 0x6F gaps 0 against 1 (no client) and 5 (Ring 5, pre-fix), morning resume writes the mode back on the first screen-on. @ryanbr — that is the overnight half; over to you.

pipiche38 added a commit to pipiche38/noop that referenced this pull request Sep 18, 2026
…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
pipiche38 added a commit to pipiche38/noop that referenced this pull request Sep 18, 2026
…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
@pipiche38
pipiche38 force-pushed the fix/oura-no-dhr-triplet-while-suspended branch from c5d27c9 to 6497e70 Compare September 18, 2026 07:33
pipiche38 added a commit to pipiche38/noop that referenced this pull request Sep 19, 2026
…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
@ryanbr

ryanbr commented Sep 21, 2026

Copy link
Copy Markdown
Owner

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:

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.

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 liveHRArmedThisSession through every site: cleared at session start, at disconnect, at teardown, and after a disable is written; set when the driver reaches .streaming with liveHRWanted, on a live push while suspended, and in reengageLiveHR().

The arming site sits inside case .streaming: behind if !reachedStreaming in advance(_:), which is what makes the doc comment literally true rather than aspirational: on the wanted path the driver only reaches .streaming after the triplet's three ACKs, so "the driver's enable triplet completed" is exactly what happened. On the suspended path the driver jumps straight to .streaming and the if driver.liveHRWanted correctly declines to arm.

The case that would otherwise regress is handled explicitly. A live push while suspended sets the flag before startReengageTimer(), so the existing self-heal still sends its disable. That is the subtle one, and you did not miss it.

Both drivers are exact twins and both default to true, so the untouched path is unchanged on either platform.

Three notes, none blocking:

  1. This wants a rebase over feat(oura): Test Centre toggle to send the official app's SetNotification mask ff (packed-notification A/B) #2361, which landed earlier today. Both touch OuraDriver on both platforms, OuraLiveSource.swift, and both extend OuraDriverTests. GitHub still reports the merge as clean, but this PR's green is against a tree that no longer exists. Worth doing before the hardware night rather than after, so the capture comes off a build that matches what would merge.

  2. The driver now has two shapes for session config. feat(oura): Test Centre toggle to send the official app's SetNotification mask ff (packed-notification A/B) #2361 established OuraDriver(notificationMask:) as immutable init-time configuration resolved through a closure. This adds public var liveHRWanted = true, mutable, with "read once at the auth-success step" guaranteed by a comment rather than by the type. I do not think that is wrong: the value genuinely is not known at construction, since liveHRSuspended can change between connect and auth, and reading it at auth is the whole point. But now that there are two shapes in one class it deserves a deliberate answer rather than an accident.

  3. The Kotlin flag is API-only, with nothing setting it until Oura Android: live-HR daytime-mode drain has no screen-state suspend (iOS/macOS-only fix in #1526) #1546. Both the description and the code comment say so plainly, which is the right way to land a one-sided change. Worth keeping that visible so nobody later reads the Kotlin property as evidence Android already got this.

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
pipiche38 added a commit to pipiche38/noop that referenced this pull request Sep 21, 2026
…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
@pipiche38
pipiche38 force-pushed the fix/oura-no-dhr-triplet-while-suspended branch from 6497e70 to 412d7a9 Compare September 21, 2026 07:19
@pipiche38

Copy link
Copy Markdown
Author

@ryanbr

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, DHR_mode:3 0/34 in the ring's 0x43 log) and the overnight half (5726339240, 2026-09-18: 18 overnight reconnects, 0 triplets). The description's "Hardware: owed" line was stale; it now points at both. A third night on the rebased tree (2026-09-20/21, 2d4f7b5c = this PR + #2361's toggle left OFF + #2300): 51 suspended overnight connects, every one auth OK - live HR suspended … daytime HR left untouched, -> dhr_enable count 0 between 22:15 and 07:50, ring held down the whole night, hypnogram complete (1144 codes), 116/116 five-minute HR bins. Nothing in this PR's path changed on the rebase, so I am not claiming that night as new evidence for the code — only that the rebased build behaves as the two reported nights did.

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. notificationMask is fixed for the life of the driver — it is decided before enableAllNotifications is built, and nothing after construction can change what the ring was told. liveHRWanted cannot be init-time: liveHRSuspended can flip between the connect and the auth-OK step (the screen going dark inside the 5-min grace does exactly that), and reading it at auth is what makes the log line and the write agree. Making it init-time would either re-create the driver at auth or read a stale answer. A closure like the mask's would work, but it would move the "read once" guarantee from a comment on a var to a comment on a closure — same guarantee, one more indirection. If you would rather have it typed, the honest shape is a session config value passed into nextStep(.authCompleted); I would do that as its own change rather than inside this one.

Note 3 stands as written: the Kotlin flag has no setter until #1546; the comment in OuraLiveSource.kt says so, and I have left it that way rather than pre-wire a suspend Android does not have.

Ready for review.

pipiche38 added a commit to pipiche38/noop that referenced this pull request Sep 22, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants