Skip to content

feat(oura): Experimental "All-day heart rate & HRV" — stand the daytime-HR hold down for the learned night, not for every screen-off - #2300

Open
pipiche38 wants to merge 3 commits into
ryanbr:mainfrom
pipiche38:feat/oura-all-day-hr-night-band
Open

pipiche38 wants to merge 3 commits into
ryanbr:mainfrom
pipiche38:feat/oura-all-day-hr-night-band

Conversation

@pipiche38

@pipiche38 pipiche38 commented Sep 17, 2026

Copy link
Copy Markdown

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 0x80 for 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 derivation BatteryEstimator.bedtimeAlert uses, 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.shouldSuspendLiveHR gains an allDay argument (.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's liveHRWanted at 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.
  • App layer: 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.kt has 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 uninterrupted CVA_state cycles) — but a dark screen is also most of a working day.

Measured on the Gen 3 ring's own DB (rrInterval, deviceId LIKE 'oura%', tsSuspect excluded), 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, hrSample per hour: 10–14 h 0, 18 h 0, 20 h 0; every daytime beat srcChannel = 1 (the 0x80 the 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.
  • StrandAnalytics 2046/0. Strand (macOS) and NOOPiOS BUILD SUCCEEDED (CI does not build the app targets). doc_comment_lint OK; i18n_audit --ci FAIL set unchanged from the base.
  • 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). Will report here before asking for review.

@pipiche38

Copy link
Copy Markdown
Author

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 auth OK - enabling live HR and nothing said which rule had decided; the first afternoon's log had to infer "toggle on, outside the band" from two hours without a SUSPENDED line and the 5 min grace. It now reads auth OK - enabling live HR (all-day HR on, outside night stand-down 22:12–08:12) (or inside …, or no learned sleep schedule yet - screen rule applies at cold start). Toggle off = the old text byte for byte. macOS target built; nothing pins the string.

Afternoon on the Gen 3 ring (iPhone, 2026-09-17, toggle ON, phone pocketed, NOOP not opened): 13:59–16:11, 449 dhr_enable = 87 % of the 15 s slots, zero SUSPENDED / live HR suspended lines; the 17 min without a hold are 12 reconnect gaps, not stand-downs. Same-day control on the build without the toggle, 11:22–12:42: 3 dhr_enable, 12 connects logged live HR suspended (screen off). The hold does not cost link stability: 22 timeouts in 132 min held vs 25 in 80 min unheld. The learned band for this ring is 22:12–08:12.

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.

@pipiche38

Copy link
Copy Markdown
Author

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!

pipiche38 and others added 3 commits September 21, 2026 08:49
…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
@pipiche38
pipiche38 force-pushed the feat/oura-all-day-hr-night-band branch from 8d26f8f to 89d7cfe Compare September 21, 2026 07:19
@pipiche38

Copy link
Copy Markdown
Author

@ryanbr this is just rebased with conflict solving on the localization. reday for review

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.

1 participant