Skip to content

Oura: persist the ring's SleepNet hypnogram as a stage-timeline sleep session, shown honestly as "Oura" - #727

Closed
pipiche38 wants to merge 1 commit into
ryanbr:mainfrom
pipiche38:oura-sleep-timeline-persist-v2
Closed

Oura: persist the ring's SleepNet hypnogram as a stage-timeline sleep session, shown honestly as "Oura"#727
pipiche38 wants to merge 1 commit into
ryanbr:mainfrom
pipiche38:oura-sleep-timeline-persist-v2

Conversation

@pipiche38

@pipiche38 pipiche38 commented Jul 22, 2026

Copy link
Copy Markdown

Summary

The Oura ring banks its whole-night SleepNet phase codes (2-bit deep/light/rem/awake, 4/byte) in one burst after wake. This PR decodes that burst, reconstructs its time axis, refines the true sleep end with the 0x49 sleep-summary window, and upserts the night as a CachedSleepSession under the ring's own deviceId — so SleepMerge's imported-over-computed rule surfaces Oura's own staging instead of a computed guess. The sleep surfaces then label that night honestly as "Oura" with a caveat that its stages are the ring's raw on-device classification.

Two halves, both platforms, byte-identical Swift/Kotlin twins:

1. Persist the hypnogram (data)

  • OuraProtocol (pure): HypnogramAssembler (30 s/code time-axis, laid backward from burst end), 0x49 sleep_summary decode + window pairing in OuraHistoryDrain, plus OuraEvents/Decoders/Framing/OuraDriver additions. No CoreBluetooth; covered by swift test / JVM tests.
  • WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → CachedSleepSession stage breakdown), OuraStreamMapping sleepPhase pass-through.
  • App: OuraLiveSource assembles the burst, anchors it to ring-time, 0x49-refines the end, and calls persistSleepSession; SourceCoordinator wires the persist closure.

2. Show provenance honestly (UI)

A persisted Oura night is the ring's own SleepNet output — not a NOOP computation — so the surfaces say so, instead of the generic "On-device":

  • One canonical resolver: DeviceBrandCatalog.isOura(deviceId) on both platforms (brand-table prefix, never an "oura" literal). Swift Repository.activeDeviceIsOura + Kotlin SleepScreen both call it.

  • Labels: hero + main-sleep badges read "Oura"; daySourceBadge gains the Oura brand so the ring-id session (and By-Day) label correctly; WHOOP + Apple imports still win the provenance order above Oura.

  • Honest caveat (ouraRawStagesNote): the stage caption reads "raw on-device stages", and a note states plainly:

    "This split is the ring's raw on-device classification read over Bluetooth, not the adjusted stages the Oura app shows. Expect more Awake and less Deep/REM here than in the Oura app for the same night."

    This is deliberate: the ring's raw BLE codes differ from the vendor's cloud-post-processed hypnogram, so the breakdown isn't mistaken for the Oura app's polished numbers.

  • Design tokens only (restColor / textTertiary / caption); read/UI-only, not stored, never crosses .noopbak (no data twin needed).

Tier discipline

The persisted session is a ring-PROVIDED night, not a NOOP-computed score. Activity/MET (Tier-B) is untouched.

Tests — automated

Tests — real hardware (2026-07-22, Oura Gen 3, macOS 9.1.0)

Validated end-to-end on a live overnight + nap, cross-checked against the WHOOP app for the same night.

Persist fired correctly:
0x49 sleep window candidate 22:09:31 → 08:25:31
hypnogram burst end refined by 0x49 — SleepNet write 12:15:37 → true sleep end 08:25:31 (−230 min)
hypnogram reconstructed [22:01:31 → 08:25:31, anchored] codes=1248
sleep session persisted [22:01:31 → 08:25:31] eff=70% → oura- (wins merge over computed)

Anti-phantom 0x49 refinement worked: the ring wrote the whole burst at 12:15 while on the charger; the refinement trimmed 3h50m of post-wake write-tail to a true end of 08:25 — without it, the night would have been mis-dated ending
at noon.

Oura vs WHOOP — close: onset 22:01 vs 22:13; wake 08:25 vs 08:02.

Stage split — diverges exactly as ouraRawStagesNote warns (raw on-device ⇒ more Awake, less Deep/REM):

Stage Oura raw (this PR) WHOOP
Awake 3:06 (30%) 1:16 (12%)
Light 5:16 (51%) 4:37 (49%)
Deep 1:13 (12%) 1:40 (16%)
REM 0:50 (8%) 2:16 (23%)
Asleep 7:19 8:33

The ~2.4× Awake and <½ REM is precisely the behaviour the caveat note describes — the honesty layer is doing its job, not a regression.

Also confirmed live:

Provenance ordering note: an Oura night shows "Oura" only while no WHOOP import covers that day; once a WHOOP export is imported for the same day, WHOOP wins the merge and the label flips to "Whoop" — by design.

Scope

Supersedes DRAFT #446 (oura-sleep-stage-timeline-persist). Clean single commit off main; no ibiHrDump/rawDump/activity-corpus carried in.

… sleep session, shown as "Oura"

The Oura ring banks its whole-night SleepNet phase codes (2-bit deep/light/rem/awake,
4/byte) in one burst after wake. This decodes that burst, reconstructs its time axis
(30 s/code, laid backward from the burst end), refines the true sleep end with the
0x49 sleep-summary window, and upserts the night as a CachedSleepSession under the
ring's own deviceId — so SleepMerge's imported-over-computed rule surfaces Oura's
staging as the night's stages instead of a computed guess.

Pipeline (both platforms, byte-identical Swift/Kotlin twins):
- OuraProtocol: HypnogramAssembler (time-axis reconstruction), 0x49 sleep_summary
  decode + window pairing in OuraHistoryDrain, OuraEvents/Decoders/Framing/OuraDriver
  additions. Pure, no CoreBluetooth; covered by swift test / JVM tests.
- WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram -> CachedSleepSession
  stage breakdown), OuraStreamMapping sleepPhase pass-through.
- App: OuraLiveSource assembles the burst, anchors + 0x49-refines the end, and calls
  persistSleepSession; SourceCoordinator wires the persist closure.

Provenance (both platforms): a persisted Oura night is the ring's OWN SleepNet output,
not a NOOP computation, so the sleep surfaces say so honestly instead of the generic
"On-device":
- DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver, brand table (never an
  "oura" literal); Swift Repository.activeDeviceIsOura + Kotlin SleepScreen call it.
- SleepView / SleepScreen: hero + main-sleep badges read "Oura"; the stage caption reads
  "raw on-device stages"; and a caveat note states the split is the ring's RAW BLE
  classification (more Awake / less Deep+REM) — NOT the Oura app's cloud-adjusted stages.
  daySourceBadge gains the Oura brand so the ring-id session (and By-Day) label correctly.
- Design tokens only (restColor / textTertiary / caption); WHOOP + Apple imports still
  win the provenance order above Oura.

Tier discipline: the persisted session is a ring-PROVIDED night, not a NOOP-computed
score. Activity/MET (Tier-B) is untouched.

Validated on a real overnight (2026-07-21): reconstructed [22:22:51 -> 08:46:51], 0x49
trimmed 50 min of post-wake write tail, session persisted eff=80%. Time-asleep 8h21 vs
the reference app's 8h20; stage split leans light vs the vendor's cloud-refined
hypnogram, as expected for the ring's raw on-device codes.

Supersedes the earlier DRAFT ryanbr#446 (oura-sleep-stage-timeline-persist); clean single
commit off main, no ibiHrDump/rawDump/activity-corpus carried in.

Verification: rebased onto 9.1.0 main. swift test OuraProtocol + WhoopStore green
(incl. DeviceBrandCatalog.isOura, both platforms). macOS Strand BUILD SUCCEEDED;
Android compileFullDebugKotlin OK. The Android unit-test RUN is blocked by an unrelated
9.1.0 test-fake gap (ryanbr#716 DeviceRegistryDao.setModel) that fails testFullDebug compile;
the added Kotlin isOura test mirrors the passing Swift one and runs once that's fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pipiche38

Copy link
Copy Markdown
Author

Still some work to do

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