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
Closed
Conversation
… 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>
Author
|
Still some work to do |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
0x49sleep-summary window, and upserts the night as aCachedSleepSessionunder 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),0x49sleep_summary decode + window pairing inOuraHistoryDrain, plusOuraEvents/Decoders/Framing/OuraDriveradditions. No CoreBluetooth; covered byswift test/ JVM tests.WhoopStore:OuraSleepSessionMapping(anchored per-code hypnogram →CachedSleepSessionstage breakdown),OuraStreamMappingsleepPhase pass-through.OuraLiveSourceassembles the burst, anchors it to ring-time,0x49-refines the end, and callspersistSleepSession;SourceCoordinatorwires 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). SwiftRepository.activeDeviceIsOura+ KotlinSleepScreenboth call it.Labels: hero + main-sleep badges read "Oura";
daySourceBadgegains 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 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
swift testOuraProtocol + WhoopStore green, incl. the newDeviceBrandCatalog.isOuracase (both platforms).compileFullDebugKotlinOK.DeviceRegistryDao.setModelwithout updating three test fakes, sotestFullDebugUnitTestcan't compile its source set. A separate one-commit fix (fix/android-registry-fake-setmodel) unblocks it; the KotlinisOuratest here mirrors the passing Swift one and runs once that lands. (PR Android: unblock testFullDebugUnitTest — implement DeviceRegistryDao.setModel in the three fake DAOs #725 is fixing it)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
0x49refinement 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 endingat noon.
Oura vs WHOOP — close: onset 22:01 vs 22:13; wake 08:25 vs 08:02.
Stage split — diverges exactly as
ouraRawStagesNotewarns (raw on-device ⇒ more Awake, less Deep/REM):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:
[13:59:31 → 14:25:31] eff=56%.INACTIVE (server-gated)— reconfirmed.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 offmain; no ibiHrDump/rawDump/activity-corpus carried in.