Repository navigation
Conversation
…, drain to bytes_left==0 The 0x11 GetEvents summary was misread on main (the old ryanbr#91 fix never merged): body[0] is events_received (a batch COUNT, not a status - the 2026-07-11 18:53 drain stopped on events==0 with 400,873 bytes STILL LEFT, abandoning the newest data incl. the night's hypnogram), and bytes[2..5] is bytes_left (a remaining-BYTE count, not a cursor - persisting it and comparing across sessions as clocks minted a phantom "ring-time regression" -> reset-to-0 -> full history re-dump on every connect; both morning/evening logs show it byte-for-byte). Package: - parseGetEventsResponse -> (eventsReceived, bytesLeft, moreData) per open_oura's EventBatchSummary; moreData = bytesLeft > 0. - 3 golden tests from the real captured summaries, incl. the exact events==0/bytes_left==400,873 case that must continue. App (OuraLiveSource): - The drain now runs until bytes_left == 0. The response byte-count is NEVER persisted. - The durable resume point (open_oura nextEventToSync) is the newest STORED history sample's ring-time (maxStoredRingTime), advanced only by samples that resolved a REAL anchored time (incl. drained pending-anchor parks; never wall-clock fallbacks), bounded by a plausibility ceiling, and committed only at drain end when it resolves under the current anchor. - Genuine ring reboot detection: a stored sample OLDER than the seek cursor (sawPreResumeData) -> full pull next connect. - Backstop guards only (a healthy drain ends at 0): bytes_left stall (3 flat summaries) and a 300s wall deadline; a forced stop keeps banked forward progress, never resets to 0. - A persisted cursor above the ceiling (pre-fix garbage) resets to a full pull at load. - Log marker [cursor-fix v1] so the running build is identifiable. swift test: 91/91. macOS app builds (BUILD SUCCEEDED). To validate on-device: connect twice - 1st drain should end "caught up (bytes_left 0)" + "resume cursor advanced", 2nd should fetch only the delta (no phantom regression, no full re-dump), and the previous night's sleep data should finally arrive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…al log Three fixes so the ring's hypnogram can be validated against WHOOP: (2) Stage mapping: OuraSleepStage now follows open_oura's VALIDATED decode_sleep_phases order (0=deep, 1=light, 2=rem, 3=awake). NOOP had 0=awake/2=deep/3=rem from the same unverified doc as the rest of s6.12; two live captures contradict it - phase records decoded AT WAKE (wearer demonstrably awake) carry code 3, which is awake under open_oura's mapping and "REM" under the old one. Raw codes persist unchanged (stage.rawValue is the wire code); only the labels move. Golden tests updated (0x4E + 0x4B: codes 1,2,3,0 -> light, rem, awake, deep). (3) PK-collapse fix (OuraStreamMapping): the event store's PK is (deviceId, ts, kind) and every code in a phase record shared the record's ts, so a whole hypnogram record collapsed to ONE surviving row (the DB held ~1 row/record; nights lost). Each code's ts is now offset by its index in seconds - an ORDER-preserving synthetic spacing, documented as provisional until the real per-code epoch is pinned; the raw code + index persist unchanged. (4) Arrival visibility (OuraLiveSource, investigation log): every 0x4B/0x4E/0x5A record now logs its anchored time, code count, and deep/light/rem/awake histogram inline, plus a per-code cadence self-check (record gap / previous record's code count, median at drain-end) - the same technique that pinned the 60s activity MET epoch. This settles when/whether the ring transmits the night's phase timeline (the drain now provably reaches the newest data, yet the night's hypnogram has not appeared in the phase rows - the log will show what actually arrives). Tests: OuraProtocol 91/91, WhoopStore 243/243, StrandAnalytics 994/994. macOS app BUILD SUCCEEDED. No Android Oura twin on main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…rt drain The 2026-07-12 morning captures delivered the ring's FULL night hypnogram (23 records x 52 codes; deep 88 / light 331 / REM 79 / awake 100 min at 30s/code - structure and totals consistent with WHOOP's staging of the same night), but every record read "[no anchor yet]": a short resume drain carries no organic 0x42 time-sync, so the session never anchored. Consequences: the hypnogram wall-clock-stamped at connect time, and the resume cursor could not commit (4 connects re-dumped the same window). Root cause: this clean stack never asks the ring for its clock - the SyncTime send (and the corrected 0x12 layout) were old-branch fixes that never merged to main. Ported both, from the on-device-proven versions: - Commands.syncTime is now the authoritative open_oura layout `12 09 <unix_secs u64 LE> <tz i8 half-hours>` (supersedes the reverse-engineered unix/256 + 0xF6-trailer guess, which did not match the native client). Tests updated + a tz-byte test added. - OuraLiveSource writes syncTime(now) once per session at .streaming, BEFORE the first history fetch, so the ring emits a usable 0x42 and every fetched record (incl. the night's hypnogram) gets its real anchored time; the resume cursor can then commit and the repeated re-dump stops. swift test: 92/92. macOS app BUILD SUCCEEDED. To validate on-device: "-> sync_time" then "UTC time anchor acquired" early in the connect, sleep-phase records with REAL night timestamps + the per-code cadence self-check line, and "resume cursor advanced" at drain end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
Upstream absorbed the ryanbr#91 cursor fix and refactored it into the pure, unit-tested OuraHistoryDrain core (ryanbr#291) — all 8 conflicts in OuraLiveSource resolved in favor of that refactor (functionally identical to this branch's inline version, better factored). Kept from this branch: the sleep-phase arrival log + per-code cadence self-check (re-added its teardown resets, which sat inside a conflict hunk), the SyncTime send, and the stage-mapping/PK-collapse/estimator work (auto-merged cleanly). Verified after merge: OuraProtocol 106/106 (incl. upstream's OuraHistoryDrainTests), WhoopStore 256/256, macOS app BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…st end
The ring's SleepNet finalizes a night AFTER wake and writes the whole
hypnogram to its event log in ONE burst (observed 2026-07-12: 23 records
x 52 codes, envelope ring-times all ~09:30 = the WRITE moment). The
envelope timestamp marks when the analysis was saved, not when the sleep
happened - anchoring per-record collapsed an entire night onto a few
seconds of PK slots (the "vanished hypnogram").
Reconstruction: a burst's codes are one contiguous sequence at 30 s/code
ENDING at the burst's envelope time. The epoch is triple-confirmed:
open_oura sleepnet.md ("classification at 30-second intervals"), the
observed window math (1,196 codes over a ~23:35-09:00 night), and the
0x49 summary's 600-minute window over those same codes.
- OuraProtocol: new pure OuraHypnogramAssembler - groups consecutive
phase records into bursts by envelope ring-time gap (60 s), lays codes
backward from the anchored burst end (codesWithTimes). 6 tests incl.
the real 23x52 night shape and a two-night split.
- OuraLiveSource: phase records feed the assembler instead of enqueueing
at envelope time; bursts persist on close/drain-end/teardown with
reconstructed per-code times (wall-clock end fallback when unanchored,
honest + logged); banked bursts advance the resume cursor; a
"hypnogram reconstructed [start -> end] ... deep/light/rem/awake min"
log makes each capture self-evident.
- OuraStreamMapping: store the given ts verbatim (the provisional
ts+index offset would double-shift reconstructed codes); 30 s spacing
now guarantees distinct (deviceId, ts, kind) rows.
Tests: OuraProtocol 112/112, WhoopStore 256/256. macOS app BUILD
SUCCEEDED. On-device validation: reconstructed night rows should appear
across the real 23:3x-09:xx window in the event table, matching the
manual reconstruction (deep 88 / light 331 / REM 79 / awake 100 min).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…wall-clock axis Resolves the review remarks on upstream ryanbr#325: PARITY BLOCKER (ryanbr): the Kotlin twin decodes the same tags with the OLD stage mapping - merging Swift-only would make byte-identical stored rows decode as deep on iOS and awake on Android. Fixed: - android OuraEvents.kt: OuraSleepStage flipped to the open_oura VALIDATED order (DEEP=0, LIGHT=1, REM=2, AWAKE=3), doc-commented as the byte-identical twin of the Swift enum. - DecoderGoldenTest: 0x6C golden updated (codes 1,2,3,0 -> light, rem, awake, deep; byte-identical to the Swift golden) + a raw-value pin test so a regression to the old mapping breaks loudly. - OuraStreamMappingTest: DEEP.raw==0 / REM.raw==2 expectations. - docs/OURA_PROTOCOL.md s6.12: the line both platforms inherited the bug from now teaches 0=deep,1=light,2=rem,3=awake (open_oura decode_sleep_phases), with a CORRECTION note, and includes 0x4B. REVIEW (tigercraft4): - Stale old-mapping doc lines removed/corrected (OuraEvents.swift leftover line above the corrected block; Decoders.swift twin). - Assembler order robustness: bursts now surface hasNonMonotonicRingTimes (arrival order is deliberately NOT re-sorted - envelope rts are near-identical write moments, so an unstable sort could scramble the true sequence); OuraLiveSource logs the flag. New test pins flagged-but-not-resorted behavior. - Wall-clock fallback removed: an unanchored burst is now HELD (pendingUnanchoredBursts, same discipline as pendingAnchorEvents) and reconstructed when the 0x42 anchor lands; if the session ends unanchored it is DROPPED with an honest log - safe, the resume cursor only advances on an anchored persist, so the ring re-serves the same records next drain. A night's time axis is never guessed. - Stored-rows note: no consumer renders OURA_SLEEP_PHASE rows yet (no sleep-session builder exists); rows written under the old labels are investigation-only. Called out in the PR thread. Tests: OuraProtocol 116/116 (swift test); macOS app BUILD SUCCEEDED. Kotlin: mirror edits verified by inspection - no local Java runtime; android.yml/upstream CI covers testFullDebugUnitTest.
…ame-window re-serve loop On-device 2026-07-12: one session re-served the same ~955 KB window five times and never reached the 409 KB tail (last night's hypnogram). Root cause is ours, not the ring's: the continuation request re-sent the SESSION-START cursor (historyCursor only commits at drain end) as an open_ring-style max=0 "ack", and it was fired the moment the 0x11 summary arrived - which the ring emits BEFORE its batch finishes streaming. Re-requesting mid-stream at a non-advancing cursor makes the ring restart its serve from that cursor. Aligned with open_oura's drain_events (crates/oura-link/src/client.rs): - Continuation = getEvents(maxSeenRingTime + 1, max=255, flags=-1) - same shape as the initial request; the max=0 ack-fetch is gone (open_oura has no such request). - The next request waits for the batch to go QUIET (1.5 s with no records, the Swift equivalent of open_oura transact()'s quiet window) instead of firing on summary receipt. - A batch that yields no records, or no cursor advance, STOPS the drain (open_oura !progressed) instead of looping until the 3-flat-summaries stall guard trips. - New OuraHistoryDrain.maxSeenRingTime tracks EVERY history record's envelope ring-time (anchored or not, plausibility-capped) for the in-session continuation; the DURABLE cursor still commits only from anchored stored samples (unchanged). - OuraEvent.envelopeRingTimestamp exposes each record's envelope ring-time; history TLV ingest routes through ingestHistory() which feeds the drain + keeps the quiet window open. - Live-HR re-engage keepalive is skipped while a drain is in flight (the Oura app never runs live mode during a sync); it resumes on the next 15 s tick. - OURA_PROTOCOL.md s5.2/s5.3 corrected: 0x11 = events_received/progress/bytes_left (no cursor), summary is NOT an end-of-batch marker, and the open_ring ack-fetch is marked SUPERSEDED with the observed re-serve evidence. Verified: OuraProtocol swift test 121/121 (5 new continuation-cursor tests + driver continuation shape repinned); macOS app xcodebuild BUILD SUCCEEDED. On-device validation next: reconnect from cursor 3595428 should stream the tail in advancing batches and deliver the 2026-07-11 night's burst. Android's Oura driver still carries the pre-ryanbr#91 summary model - upstream's tracked Android follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…t / deadline backlog) 0x71 green_ibi_amp (ryanbr#287/ryanbr#333): every occurrence now logs with anchored time, envelope rt, payload length, and full raw hex (was once-per-kind with no timestamp) - a verified decoder needs several real payloads, and the anchored time is what aligns each record with the concurrent live-HR R-R ground truth. Capped at 50/session against a night-long green-IBI wall; a drain-end roll-up reports the session count + distinct payload lengths regardless. Drain self-resume: a drain that ends with KNOWN remaining work now schedules its own next pass 5 s later instead of waiting for a manual reconnect or the 15 min periodic fetch: - ring reboot detected (sawPreResumeData -> cursor honestly reset to 0): the full re-pull starts immediately on the same connection; - deadline-guard stop with backlog: progress banked, next pass continues from the new cursor. A STALL/no-progress stop never chains (that is the ring looping). Capped at 6 passes per session; a healthy bytes_left==0 completion re-arms the cap. Timer torn down on stop/ disconnect/connect like the other drain timers. Verified: OuraProtocol swift test all suites 0 failures; macOS app BUILD SUCCEEDED. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
… solved, 0x60 flagged suspect Ingested github.com/ringverse/protocol/oura (the [ringverse] source our doc already cited piecemeal). Three findings actioned: 1. 0x71 green_ibi_and_amp: ringverse carries the COMPLETE firmware-addressed decoder (p_green_ibi_and_amp @0x503960) - exact scrambled bit map for 5x 11-bit IBIs + 5 amplitude mantissas, shift=(s==7?0:s+1) from payload[13] bits [2:0], bit [3] reserved (set = firmware mismatch), first emitted entry amplitude-only, timestamps walk backward by each IBI. Ported verbatim as OuraDecoders.decodeGreenIBIAmpCandidate (Tier-B ryanbr#287, strict len-14 gate, never stored) + round-trip/gate golden tests via an inverse packer. The 0x71 fixture-capture log now prints the candidate decode (shift/ibis_ms/amps) side by side with the raw hex, so captures are immediately comparable to concurrent live R-R. OURA_PROTOCOL.md s6.2 rewritten with the full layout + correction of the old "5 deltas + 6 amplitudes" summary. 2. 0x49 sleep_summary_1 SOLVED: both uint16 LE fields are minutes-before-event offsets - the tracked sleep window is [event-start_off, event-end_off]. VALIDATED against our own samples: 600/10 on the 7/11->12 night = 23:30->09:20, matching the reconstructed hypnogram and the reported sleep. Every 0x49 now logs the derived window as an independent cross-check of the reconstruction axis (Tier-B, log-only). s6.12 updated; future refinement noted (anchor burst end at event-end_off instead of the write moment). 3. 0x60 decodeIBIAmplitude flagged SUSPECT (s6.1 WARNING): ringverse shows the 11-bit IBI pieces are scrambled across non-adjacent bytes; our sequential BitReader read disagrees. Cross-check vs live R-R scheduled with the parked history-IBI work - no behavior change tonight. Verified: OuraProtocol swift test 123/123 (2 new); macOS app BUILD SUCCEEDED. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…anchorless-session gap On-device 2026-07-13 morning: a mechanically perfect drain (45 advancing batches, bytes_left 253839 -> 0) ended with NOTHING anchored - no 0x42 arrived all session, so the night's 1040-code hypnogram burst parked honestly and the resume cursor could not advance. Root cause: the 0x42 time_sync_ind record is only LOGGED when the ring actually adjusts its clock; on an already-synced ring (we SyncTime every connect now) a session may never see one. The anchor was luck-based. Fix per ringverse BLE.md: the SyncTime RESPONSE (0x13) carries the ring's CURRENT device timestamp - `13 05 <device_ts:4 LE> <status:1>`. Paired with the host wall-clock at receipt this is a deterministic ring-time->UTC anchor available at EVERY connect: - OuraFraming.parseSyncTimeResponse + syncTimeResponseOp (0x13 peeked non-destructively like 0x11/0x0D). - OuraDriver.syncTimeAnchorCandidate (pure): ringverse labels the field "seconds" but the record clock runs 100 ms ticks - try raw-ticks and seconds x10 against [historyCursor, cursor + 7 days]; exactly ONE must fit, else nil (ambiguous or fresh cursor -> log-only, never a guessed anchor). - OuraDriver.adoptSyncTimeAnchor: same 2020-2035 plausibility gate as 0x42/0x85. - OuraLiveSource.handleSyncTimeResponse: on success logs the adopted rt + unit and drains everything parked while unanchored; on ambiguity logs the raw value for investigation. Since the 0x13 lands right after connect (before the drain serves data), history now anchors inline and the drain-end cursor commit resolves. - OURA_PROTOCOL.md s5.4 rewritten: correct u64+tz request layout (the old open_ring token/counter/0xf6 guess was still documented), response layout, anchor policy. Verified: OuraProtocol swift test 126/126 (3 new); macOS app BUILD SUCCEEDED. On-device next connect should log "UTC anchor from SyncTime response (0x13)" and immediately reconstruct the re-served night. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…e moment trailed by 43 min)
On-device 2026-07-13 (first run with the 0x13 anchor - which worked exactly as designed:
deterministic anchor at connect, unit resolved as ticks, cursor finally advanced): the
night reconstructed at 00:11->08:51 while the wearer slept 23:34->08:03. The burst end was
anchored at the SleepNet WRITE moment, which trailed the true sleep end by 43 minutes and
shifted the whole night. The same drain's 0x49 window (event-559/-43 min = 23:32->08:08)
matched the wearer's report within minutes - so the parked refinement is now implemented:
- The 0x49 sleep_summary_1 offsets are stashed (lastSleepWindow049) and paired with the
hypnogram burst of the SAME finalization (envelope ring-times within 10 min - observed
seconds apart, the 0x49 arrives right before the phase records).
- The burst end then anchors at event - end_offset (the true sleep end) instead of the
write envelope; sanity-gated (true end must precede the write by < 6 h) with the write
moment as fallback when no 0x49 matched. Refinement is logged
("burst end refined by 0x49 - SleepNet write ... -> true sleep end ...").
- OURA_PROTOCOL.md s6.12 updated (refinement implemented, +43 min evidence).
Expected axis for last night after a re-pull: ~23:28->08:08 vs reported 23:34->08:03.
Note: rows persisted under the old write-anchored axis are investigation-only (no consumer
renders OURA_SLEEP_PHASE yet); a bulk clean is already on the TODO with the rrInterval one.
Verified: OuraProtocol swift test all green; macOS app BUILD SUCCEEDED.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…t, PR ryanbr#325 review) ryanbr is right that the Android twin exists and must not diverge - it carried every bug Swift fixed this cycle. This ports the complete chain, file by file, with the same fixes, the same comments, and JVM twin tests mirroring the Swift suites (same fixtures/values): - Framing.kt: 0x11 summary corrected to open_oura EventBatchSummary (events_received/progress/bytes_left - the old cursor misread WAS the ryanbr#91 full re-dump loop, still live here); 0x13 SyncTime response parse (deterministic anchor pair); lenient parseRecord + ONE-packet-per-notification reassembler (twin of dae3d7a, the phantom-storm fix - the old buffering loop was still live here too). - Commands.kt: syncTime -> u64 seconds LE + tz half-hours (on-device-proven layout; the open_ring token/counter/0xF6 guess never anchored real hardware). - OuraDriver.kt: continuation fetch at the ADVANCED cursor (max=255, open_oura drain_events; the max=0 "ack" caused the same-window re-serve loop); syncTimeAnchorCandidate (raw-ticks vs seconds x10 disambiguation against the persisted cursor) + adoptSyncTimeAnchor (same 2020-2035 plausibility gate); 0x4B routed to the validated phase decode. - EventTags.kt: SLEEP_SUMMARY_B(0x4B) -> SLEEP_PHASE_B, Tier A (twin of dddbe95 - the divergence flagged in the PR ryanbr#325 review). - OuraEvents.kt: envelopeRingTimestamp (feeds the in-session continuation cursor). - Decoders.kt: decodeGreenIBIAmpCandidate (ringverse p_green_ibi_and_amp @0x503960, Tier-B ryanbr#287, log-only). - NEW OuraHistoryDrain.kt: seen/stored two-cursor drain core (ryanbr#91/ryanbr#291) - guards, progress test, reboot detection, cursor commit rules. - NEW HypnogramAssembler.kt: burst grouping + 30s/code backward time-axis reconstruction. - ble/OuraLiveSource.kt: sync_time now sent at streaming (was never sent!); 0x13 anchor handler; quiet-window batch continuation + no-progress stop; drain-end cursor commit (stored-anchored only) + self-chained passes (reboot/deadline backlog); hypnogram burst persistence with park-until-anchor + honest teardown drop + the 0x49 true-sleep- end refinement; 0x71 fixture-capture log with candidate decode; per-session resets. STORED-DATA PARITY: OURA_SLEEP_PHASE rows now get the same reconstructed time axis on both platforms (per-code envelope-time rows were the old Android behavior - the whole night collapsed onto the write moment). Verified: compileFullDebugKotlin BUILD SUCCESSFUL; testFullDebugUnitTest 2647 tests - all com.noop.oura + OuraStreamMapping suites green incl. 30 new/updated twin tests (OuraHistoryDrainTest, HypnogramAssemblerTest, 0x71 round-trip/gates, 0x13/candidate, one-packet reassembler, continuation-shape pins). The 3 remaining failures (AiCoachContextTest, StandardHrSensorFormatTest x2) fail identically on the untouched tree - pre-existing locale/env issues on this machine, not this change. BLE session choreography is inspection-only (no Android device on hand) - same validation ask as the Swift side had before its on-device passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…session
Bank the anchored Oura hypnogram as a CachedSleepSession with a
[{start,end,stage}] breakdown, upserted under the ring's own deviceId so
SleepMerge's imported-over-computed rule surfaces Oura's SleepNet staging over
NOOP's sparse-motion computed night ("richer record wins" — the same
arbitration that already picks a WHOOP/HC import over a computed night). Reuses
the existing ryanbr#988/HC stage-timeline path; no new merge inputs, read-id plumbing,
or schema migration. Sidesteps the ryanbr#960 Tier-B durable-rows pin: sleep phases
are Tier-A provided data, not a Tier-B derivation.
- Pure byte-identical mappers (Swift OuraSleepSessionMapping + Kotlin twin);
stagesJSON hand-built in fixed key order; 6+6 tests assert the JSON verbatim.
- Wired via a new persistSleepSession closure in OuraLiveSource ->
store.upsertSleepSessions, keyed by the ring deviceId. A confirmation strap-log
line ("sleep session persisted ...") makes the persist observable on-device.
Follow-on to ryanbr#325 (hypnogram reconstruction). App-target Swift compiled (Strand
macOS + NOOPiOS); WhoopStore swift test + Android compile green. On-hardware
validation pending.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
|
Reviewed — the engineering here is solid and the parity work is genuinely careful:
On your open question — yes, I'd make it display-only for now. The blocker is the one you flagged yourself: the persisted night wins SleepMerge's imported-over-computed rule and therefore feeds the recovery gate, while your own cross-check shows deep sleep ~2× off WHOOP and the Concretely, two asks before this is mergeable:
One lower-priority note: at ~2.9k lines / 20 commits the PR bundles the Nice work overall — the display-only path is a merge-ready feature on its own; it's specifically the recovery-gate feed that needs to wait for validation. |
|
@ryanbr Thanks again for the careful review — parity/Tier-A framing all landed as intended.
But I still beleive that #325 should be ok for merge |
|
Cross-ref: #375 (vishk23) fixes the same main-session-selection concern on the Oura import paths — a short nap/fragment claiming a day's sleep rollup. This PR is the BLE live-persist twin of that path, and shares the exposure: it currently persists every hypnogram burst, so a low-efficiency daytime fragment can bank as a day's "Oura sleep" (confirmed on-device — two overlapping ~26/52-min eff-0.2 midday sessions alongside the real 572-min night). The paths don't overlap in code (import parser vs OuraLiveSource), so #375 won't cover this one. Follow-up here will mirror #375's principle — only the main night drives the day — but keyed off duration/SleepStageTotals.mainNightIndex rather than the API's type == "long_sleep" field, which the BLE stream doesn't carry. We do set restingHr/avgHrv to nil, so the impossible-RHR symptom #375 documents can't transfer here; the residual risk is a fragment shown as the night. |
|
This is really nicely built — the byte-identical Swift/Kotlin mappers with verbatim-JSON tests, reusing the existing #988/HC merge path with no new schema, and keeping it On that open question — your instinct is right: make it display-only for now. I confirmed there's no guard today, so the persisted night lands on the imported side and, via imported-wins, feeds
Deep sleep is a material recovery input, so a 2×-off value (either direction) makes the recovery score materially wrong — even for experimental Oura users, that's a misleading number rather than instrumentation. So: Requested change: persist + display the Oura hypnogram (great to have the ring's SleepNet staging on the Sleep tab), but exclude Oura-sourced sleep from the recovery / Two housekeeping items before it can land:
Happy to look again as soon as it's display-only + rebased. |
|
Kindly check PR #325 , that is the initial PR focussed on only Display |
|
That PR is well progression. |
The 0x49 sleep-window refinement kept only the LATEST window in a single slot. When an overnight burst and a daytime nap burst finalize in the same history drain (2026-07-17 capture), the nap's 0x49 overwrote the overnight's before the overnight burst persisted. The gap check then failed and the overnight fell back to its SleepNet WRITE time (10:58, ~4 h after the true 07:00 sleep end) instead of the 0x49-refined end -- persisting the night as 01:52->10:58 local rather than 22:00->07:00. Keep a bounded collection of recent 0x49 windows (cap 16) and pair each burst with the CLOSEST window by ring-time within the same 10 min tolerance. The picker is a pure static twin on both platforms (Swift closestSleepWindow049 / Kotlin companion closestSleepWindow049), byte-identical selection, so the cross-platform parity contract holds and the pairing is unit-testable with no strap. Verification: - Swift: xcodebuild -scheme Strand -destination platform=macOS -only-testing:StrandTests/OuraSleepWindowPairingTests -> 6/6 pass (whole app target compiled). - Android: ./gradlew testFullDebugUnitTest --tests com.noop.ble.OuraSleepWindowPairingTest -> 6/6 pass; compileFullDebugKotlin clean. Both suites include the overnight+nap regression that produced the +4 h mis-anchor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Oura overnight IBI-HR vs WHOOPNight of 2026-07-16, 22:00 → 06:58 Corrected window changes everything. This is a clean, validated result — and it confirms the staging build does carry the merged #511 decoders.
Nocturnal dip (Malik-filtered hourly): 66 → 63 → 64 → 54 → 54 → 51 → 55 → 52 → 52 bpm. Textbook: elevated at onset, floor in the back half, slight pre-wake rise. What this means
|
Oura Baseline vs WHOOP, with BLE Nights Placed On ItRobust Oura baseline (267 of your own Oura-official nights) vs WHOOP, with our BLE nights placed on it:
Two clear takeaways1. deep/light divergence from WHOOP is method, not decode. Our BLE deep% (8.7%, 14.7%) sits squarely inside Oura's own range and near its 12.7% mean — nowhere near WHOOP's 21.6%. Same for light. So the ring's staging, as we decode it, behaves like Oura, not WHOOP. That question is answered. 2. But the check isolated one real flag: REM. Both BLE nights read ~14% REM, while Oura's own method gives you ~20% (and so does WHOOP). That's a ~5–6-point under-call against Oura itself, consistently across both nights — so it's not method difference and not noise. That points at our REM code-mapping in the hypnogram decode, and it's worth a look. This is exactly the kind of thing the WHOOP comparison alone couldn't tell you, and your import idea surfaced it. Caveat
|
|
oura … 2026-07-18 12:00:08 → 12:26:08 UTC = 14:00 → 14:26 local 26 min eff 0.67 What this log showsA nap, cleanly anchored — this is the nap you meant. The full chain fired:
REM 0 is expected for a 26-min nap; deep-heavy power nap, sane. But it still isn't the fix testTwo reasons, both unchanged:
One thing worth notingThe nap did not hijack the 7/18 sleep-day — the top-of-log summary still shows day=2026-07-18 source=computed, i.e. the 26-min nap did not win the day as a bogus "night." That's the good outcome, NetThe validation state is exactly where it was: bug reproduced (7/16→17), fix still unexercised. You now have a nap and an overnight sitting in the DB, but they drained in separate syncs. The
|
The Test Centre report bundle gathered report.txt + raw-capture.jsonl (the
WHOOP frame capture) + meta.json, but never the Oura ring's Tier-B JSONL
sidecars (oura-raw / oura-ibihr / oura-activity), so a maintainer could only
get the redacted log lines, not the raw corpora needed to reframe/validate.
Attach them WHEN PRESENT (mirroring raw-capture.jsonl), not behind a test
domain: they cut across Sleep / HRV / Connection / Sources, and file presence
is the honest gate (no ring used -> no files -> nothing attached, so an
Experimental ring needs no separate brand check). The ring UUID inside each
line is masked to <device> by the existing redactEntries pass; entry names are
normalized (id dropped) since redaction never touches names.
- ouraDiagnosticEntries()/normalizedOuraEntryName(): enumerate
<App Support>/OpenWhoop/Diagnostics for oura-{raw,ibihr,activity}-*.jsonl,
map to id-free names, keep the largest file per name (read-only; never
creates the dir so a bundle build has no side effects).
- capEntries(): generalize the raw-capture-only tail-trim to a trimmableNames
set and split the budget across trimmable files in proportion to size. With a
single trimmable file this is byte-identical to the prior behaviour (its share
is the whole remainder), so the existing cap test still passes.
- whoop.sqlite is deliberately NOT attached (large, binary, unredactable;
meta.json already reports db_bytes + row counts).
App-target Swift (CI doesn't cover it): built Strand (macOS) + NOOPiOS, and
TestBundleAssemblerTests green 8/8 (5 existing + 3 new).
Verified on test/oura-persist-activity-combined; to be retrofitted onto the
per-feature diagnostics branches (oura-sleep-stage-timeline-persist, the
activity/ibihr branches).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
…t (fixes hang)
The review-before-share sheet renders every text bundle entry as a single
SwiftUI Text. When a large research stream is attached — the ~12 MB Oura
oura-raw.jsonl sidecar, or the WHOOP raw-capture — SwiftUI/CoreText tries to lay
out megabytes of text as one Text, allocating >1 GB of glyph storage and pinning
the main thread indefinitely, so generating a report bundle appears to hang.
Diagnosed from a main-thread sample: 100% of samples in
SheetContentRoot.sizeThatFits -> StyledTextLayoutEngine.sizeThatFits ->
CTLineCreateWithAttributedString -> TGlyphEncoder::EncodeChars, at a 1.1 GB
footprint.
ReportReviewGate.previewText now excludes the large raw streams by name
(screenshot.png + raw-capture.jsonl + the oura-{raw,ibihr,activity}.jsonl
sidecars) AND anything over a 1 MiB per-entry guard, so any oversized stream
(a future one, or a pathologically large report.txt) is named in the "attached"
note rather than laid out. report.txt / meta.json / last-crash.txt still show in
full, so the review still shows exactly the report body the user shares; the
excluded streams stay in the .zip and are named so the review stays honest.
Self-contained (no dependency on the Test Centre assembler), so it applies to
main and every feature branch that attaches diagnostics. App-target Swift (CI
doesn't cover it): built Strand (macOS), ReportReviewGateTests 4/4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bj38eZ4JMejA813XhYvMk6
… sleep session 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. Tier discipline: the persisted session is a ring-PROVIDED night (Oura's own SleepNet output), not a NOOP-computed score. Activity/MET (Tier-B) is untouched by this change. 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 (near-exact); 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. Tests: OuraProtocol 129, WhoopStore 275, Android com.noop.oura.* all green; Strand macOS BUILD SUCCEEDED; Android compileFullDebugKotlin clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016DN6cU2ERWZUS9tWYCjPvi
… 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>
… 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>
… sleep session, shown as "Oura" Decode the ring's whole-night SleepNet phase-code burst, reconstruct its 30 s/code time axis, anchor+refine it with the 0x49 sleep-summary window (BOTH ends), and upsert 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. Persist (both platforms, byte-identical Swift/Kotlin twins): - OuraProtocol: HypnogramAssembler time-axis reconstruction with the 0x49 window applied at BOTH ends — the end anchored to the sleep-end, and the START clamped to the sleep-onset so the burst's pre-window epochs are clipped (the clip lives in the PURE codesWithTimes(…, sleepStartUnixSeconds:) with a never-empty guard, so a mis-paired 0x49 can never erase the night); 0x49 pairing in OuraHistoryDrain; OuraEvents/Decoders/Framing/OuraDriver additions. - WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → stage breakdown), OuraStreamMapping sleepPhase pass-through. - App: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, calls persistSleepSession; SourceCoordinator wires the persist closure. Provenance UI (both platforms): 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": - DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver (brand table, no "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"; 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; 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. Supersedes DRAFT ryanbr#446; docs/OURA_PROTOCOL.md updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… sleep session, shown as "Oura" Decode the ring's whole-night SleepNet phase-code burst, reconstruct its 30 s/code time axis, anchor+refine it with the 0x49 sleep-summary window (BOTH ends), and upsert 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. Persist (both platforms, byte-identical Swift/Kotlin twins): - OuraProtocol: HypnogramAssembler time-axis reconstruction with the 0x49 window applied at BOTH ends — the end anchored to the sleep-end, and the START clamped to the sleep-onset so the burst's pre-window epochs are clipped (the clip lives in the PURE codesWithTimes(…, sleepStartUnixSeconds:) with a never-empty guard, so a mis-paired 0x49 can never erase the night); 0x49 pairing in OuraHistoryDrain; OuraEvents/Decoders/Framing/OuraDriver additions. - WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → stage breakdown), OuraStreamMapping sleepPhase pass-through. - App: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, calls persistSleepSession; SourceCoordinator wires the persist closure. Provenance UI (both platforms): 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": - DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver (brand table, no "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"; 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; 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. Supersedes DRAFT ryanbr#446; docs/OURA_PROTOCOL.md updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… sleep session, shown as "Oura" Decode the ring's whole-night SleepNet phase-code burst, reconstruct its 30 s/code time axis, anchor+refine it with the 0x49 sleep-summary window (BOTH ends), and upsert 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. Persist (both platforms, byte-identical Swift/Kotlin twins): - OuraProtocol: HypnogramAssembler time-axis reconstruction with the 0x49 window applied at BOTH ends — the end anchored to the sleep-end, and the START clamped to the sleep-onset so the burst's pre-window epochs are clipped (the clip lives in the PURE codesWithTimes(…, sleepStartUnixSeconds:) with a never-empty guard, so a mis-paired 0x49 can never erase the night); 0x49 pairing in OuraHistoryDrain; OuraEvents/Decoders/Framing/OuraDriver additions. - WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → stage breakdown), OuraStreamMapping sleepPhase pass-through. - App: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, calls persistSleepSession; SourceCoordinator wires the persist closure. Provenance UI (both platforms): 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": - DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver (brand table, no "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"; 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; 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. Supersedes DRAFT ryanbr#446; docs/OURA_PROTOCOL.md updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… labelled "Oura" (#773) * feat(oura): persist the ring's SleepNet hypnogram as a stage-timeline sleep session, shown as "Oura" Decode the ring's whole-night SleepNet phase-code burst, reconstruct its 30 s/code time axis, anchor+refine it with the 0x49 sleep-summary window (BOTH ends), and upsert 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. Persist (both platforms, byte-identical Swift/Kotlin twins): - OuraProtocol: HypnogramAssembler time-axis reconstruction with the 0x49 window applied at BOTH ends — the end anchored to the sleep-end, and the START clamped to the sleep-onset so the burst's pre-window epochs are clipped (the clip lives in the PURE codesWithTimes(…, sleepStartUnixSeconds:) with a never-empty guard, so a mis-paired 0x49 can never erase the night); 0x49 pairing in OuraHistoryDrain; OuraEvents/Decoders/Framing/OuraDriver additions. - WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → stage breakdown), OuraStreamMapping sleepPhase pass-through. - App: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, calls persistSleepSession; SourceCoordinator wires the persist closure. Provenance UI (both platforms): 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": - DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver (brand table, no "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"; 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; 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. Supersedes DRAFT #446; docs/OURA_PROTOCOL.md updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(oura): coverage for the SleepNet persist, "Oura" provenance, and 0x49 start-clamp Unit tests for the feature above, on both platforms (byte-identical Swift/Kotlin fixtures): - HypnogramAssembler: the 0x49 start-clamp in the pure codesWithTimes — drops codes before the onset, inclusive boundary, an onset before the burst keeps all, and the never-empty fallback for a mis-paired 0x49; plus the existing backward-layout cases. - OuraSleepSessionMapping: anchored per-code hypnogram → CachedSleepSession stage breakdown. - OuraSleepWindowPairing: closest-0x49-window selection (overnight vs nap, tolerance, empties). - DeviceBrandCatalog.isOura: registry-id → brand resolution (Oura true; WHOOP/computed/import false). - OuraStreamMapping / Decoder golden / Framing / Driver / HistoryDrain: the twin decode + mapping tests. swift test OuraProtocol + WhoopStore green; the Kotlin twins mirror them (their run is gated only by the unrelated #716 test-fake compile gap / PR #725). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * i18n(oura): localize the Oura raw-stages caveat (Apple) + baseline it (Android) The SleepNet provenance caveat added with the Oura raw-stages note was new UI copy the i18n gate (`Tools/i18n_audit.py --ci`) flagged: on Apple the two literals were absent from their String Catalog; on Android they were new hardcoded literals not in the baseline. - Apple: add both strings — the `Text` caveat and its `accessibilityLabel` — to Strand/Resources/Localizable.xcstrings with translations for all 8 focus locales (de, es, fr, it, pt-PT, ru, zh-Hans, zh-Hant), state `translated`, mirroring the sibling "Low confidence staging" note's terminology (deep/REM, efficiency, stages, "anneau/Ring/anillo/…"). - Android: `SleepScreen.kt` is fully hardcoded (0 stringResource, 6 entries already baselined) and has no low-confidence twin, so the two literals join the accepted backlog via `--update-baseline` — the diff is exactly the two SleepScreen.kt entries, nothing else. (Localizing the Android sleep screen's copy is a separate backlog task; this only closes the NEW-literal regression.) `python3 Tools/i18n_audit.py --ci origin/main` now exits 0 (Android: no new hardcoded literals + focus locales complete; Apple: no new un-extracted literals + all catalogs complete). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The Home Screen widget showed a large Charge percentage with sparse footer icons; Effort and Rest were already in WidgetSnapshot from #446 but unused on the compact families. Small and medium now show the three daily scores as rings in Today's order, large keeps its stat grid beneath them, and the Lock Screen rectangular accessory carries the same trio. Rings are static rather than GlowRing's draw-in animation: WidgetKit does not reliably fire onAppear, so an animated ring can freeze empty until the next timeline rebuild. An unscored day renders as a dash over an empty track, never a fabricated zero. Effort honours the #313 scale (#1022 review): the display string carries the WHOOP 0-21 value while the arc fraction stays on the stored 0-100 axis, so rings stay comparable between users on different scales. Pre-formatted at publish time because effortScaleKey is a plain @AppStorage outside the App Group. The new WidgetSnapshot fields are optional, so an already-persisted snapshot still decodes. Merged with --admin: this fork PR's workflows were never triggered, so it carried no checks. Verified out of band instead - app-build green on both legs against current main (the only compile these 241 lines have had), plus doc-comments and i18n run locally on the merge result. iOS-only, so Android CI does not apply.
… labelled "Oura" (ryanbr#773) * feat(oura): persist the ring's SleepNet hypnogram as a stage-timeline sleep session, shown as "Oura" Decode the ring's whole-night SleepNet phase-code burst, reconstruct its 30 s/code time axis, anchor+refine it with the 0x49 sleep-summary window (BOTH ends), and upsert 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. Persist (both platforms, byte-identical Swift/Kotlin twins): - OuraProtocol: HypnogramAssembler time-axis reconstruction with the 0x49 window applied at BOTH ends — the end anchored to the sleep-end, and the START clamped to the sleep-onset so the burst's pre-window epochs are clipped (the clip lives in the PURE codesWithTimes(…, sleepStartUnixSeconds:) with a never-empty guard, so a mis-paired 0x49 can never erase the night); 0x49 pairing in OuraHistoryDrain; OuraEvents/Decoders/Framing/OuraDriver additions. - WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → stage breakdown), OuraStreamMapping sleepPhase pass-through. - App: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, calls persistSleepSession; SourceCoordinator wires the persist closure. Provenance UI (both platforms): 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": - DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver (brand table, no "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"; 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; 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. Supersedes DRAFT ryanbr#446; docs/OURA_PROTOCOL.md updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(oura): coverage for the SleepNet persist, "Oura" provenance, and 0x49 start-clamp Unit tests for the feature above, on both platforms (byte-identical Swift/Kotlin fixtures): - HypnogramAssembler: the 0x49 start-clamp in the pure codesWithTimes — drops codes before the onset, inclusive boundary, an onset before the burst keeps all, and the never-empty fallback for a mis-paired 0x49; plus the existing backward-layout cases. - OuraSleepSessionMapping: anchored per-code hypnogram → CachedSleepSession stage breakdown. - OuraSleepWindowPairing: closest-0x49-window selection (overnight vs nap, tolerance, empties). - DeviceBrandCatalog.isOura: registry-id → brand resolution (Oura true; WHOOP/computed/import false). - OuraStreamMapping / Decoder golden / Framing / Driver / HistoryDrain: the twin decode + mapping tests. swift test OuraProtocol + WhoopStore green; the Kotlin twins mirror them (their run is gated only by the unrelated ryanbr#716 test-fake compile gap / PR ryanbr#725). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * i18n(oura): localize the Oura raw-stages caveat (Apple) + baseline it (Android) The SleepNet provenance caveat added with the Oura raw-stages note was new UI copy the i18n gate (`Tools/i18n_audit.py --ci`) flagged: on Apple the two literals were absent from their String Catalog; on Android they were new hardcoded literals not in the baseline. - Apple: add both strings — the `Text` caveat and its `accessibilityLabel` — to Strand/Resources/Localizable.xcstrings with translations for all 8 focus locales (de, es, fr, it, pt-PT, ru, zh-Hans, zh-Hant), state `translated`, mirroring the sibling "Low confidence staging" note's terminology (deep/REM, efficiency, stages, "anneau/Ring/anillo/…"). - Android: `SleepScreen.kt` is fully hardcoded (0 stringResource, 6 entries already baselined) and has no low-confidence twin, so the two literals join the accepted backlog via `--update-baseline` — the diff is exactly the two SleepScreen.kt entries, nothing else. (Localizing the Android sleep screen's copy is a separate backlog task; this only closes the NEW-literal regression.) `python3 Tools/i18n_audit.py --ci origin/main` now exits 0 (Android: no new hardcoded literals + focus locales complete; Apple: no new un-extracted literals + all catalogs complete). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The Home Screen widget showed a large Charge percentage with sparse footer icons; Effort and Rest were already in WidgetSnapshot from ryanbr#446 but unused on the compact families. Small and medium now show the three daily scores as rings in Today's order, large keeps its stat grid beneath them, and the Lock Screen rectangular accessory carries the same trio. Rings are static rather than GlowRing's draw-in animation: WidgetKit does not reliably fire onAppear, so an animated ring can freeze empty until the next timeline rebuild. An unscored day renders as a dash over an empty track, never a fabricated zero. Effort honours the ryanbr#313 scale (ryanbr#1022 review): the display string carries the WHOOP 0-21 value while the arc fraction stays on the stored 0-100 axis, so rings stay comparable between users on different scales. Pre-formatted at publish time because effortScaleKey is a plain @AppStorage outside the App Group. The new WidgetSnapshot fields are optional, so an already-persisted snapshot still decodes. Merged with --admin: this fork PR's workflows were never triggered, so it carried no checks. Verified out of band instead - app-build green on both legs against current main (the only compile these 241 lines have had), plus doc-comments and i18n run locally on the merge result. iOS-only, so Android CI does not apply.





Follow-on to #325. Persists the reconstructed Oura hypnogram as a real night with a stage breakdown.
What
Banks the anchored hypnogram as a
CachedSleepSession([{start,end,stage}]stagesJSON) under the ring'sown deviceId — the imported/measured side, not the
-noopcomputed sibling. SleepMerge's existingimported-over-computed rule then surfaces Oura's SleepNet staging over NOOP's sparse-motion computed night
("richer record wins" — the same arbitration that picks a WHOOP/HC import over a computed night). Reuses the
#988/HC stage-timeline path: no new merge inputs, read-id plumbing, or schema migration.
Sidesteps the #960 Tier-B durable-rows pin: sleep phases are Tier-A provided data (the ring's own
classifier), only reshaped — not a Tier-B derivation like the activity MET estimate.
How
OuraSleepSessionMapping+ Kotlin twin);stagesJSONhand-built infixed key order. 6 + 6 tests assert the JSON verbatim.
OuraLiveSource.persistHypnogramBurstbuilds the session from the same anchored + 0x49-refined codes andupserts via a new
persistSleepSessionclosure →store.upsertSleepSessions, keyed by the ring deviceId(idempotent by startTs). A
sleep session persisted …strap-log line makes it observable on-device.Verification
Strand(macOS) +NOOPiOS(iOS). WhoopStoreswift test+ Android compile green.Strand/BLEpath, so compile proves nothing about thedrain → persist → merge → display chain. Multi-night plan (tracks a varying input; cross-checked vs WHOOP +
wearer-reported) tracked on the branch.
Open question for review
The persisted night feeds the recovery gate via imported-wins. I read this as compliant (ring's own Tier-A
classification; Oura is
ExperimentalBrand-gated), but a cross-check showed deep sleep ~2× off WHOOP — opento making it display-only instead.