Skip to content

feat(oura): reconstruct BLE sleep-stage hypnogram - #325

Closed
pipiche38 wants to merge 20 commits into
ryanbr:mainfrom
pipiche38:oura-hypnogram-reconstruction
Closed

feat(oura): reconstruct BLE sleep-stage hypnogram#325
pipiche38 wants to merge 20 commits into
ryanbr:mainfrom
pipiche38:oura-hypnogram-reconstruction

Conversation

@pipiche38

Copy link
Copy Markdown

Summary

This PR makes the ring's own sleep analysis (SleepNet, computed on-device) fully recoverable over BLE: the night hypnogram now decodes with correct stage labels, gets a real time axis, and persists as a 30-second stage timeline
— validated against a WHOOP reference for the same night. It also fixes the missing time anchor that left short drains unstamped.

Changes

  1. SyncTime (0x12) on connect — corrected layout (Commands.swift, OuraLiveSource)
    The app never asked the ring for its clock, so a short resume drain carried no 0x42 anchor: every fetched record stayed [no anchor yet] and the resume cursor could never commit (observed: 4 connects re-dumping the same window).
    Now sends SyncTime once per session before the first fetch, using the authoritative open_oura layout (12 09 ), superseding the earlier reverse-engineered guess (unix/256 + 0xF6 trailer).
    On-device: anchor lands within ~1 s of connect.

  2. Sleep-stage mapping corrected (OuraEvents.swift)
    OuraSleepStage now follows open_oura's validated order (0=deep, 1=light, 2=rem, 3=awake); the old mapping (0=awake/2=deep/3=rem) came from an unverified doc and was contradicted by live captures (records decoded at wake carry
    code 3). Raw wire codes persist unchanged — labels only.

  3. Hypnogram time-axis reconstruction (new OuraHypnogramAssembler + OuraLiveSource wiring)
    Key protocol finding: the ring finalizes a night after wake and writes the whole hypnogram in one burst — every record's envelope ring-time marks the write moment (~09:30), not the sleep. Persisting at envelope time collapsed
    an entire night onto seconds. The new pure assembler groups consecutive phase records into bursts (ring-time gap) and lays the codes backward at 30 s/code from the anchored burst end. The epoch is triple-confirmed: open_oura's
    sleepnet.md ("classification at 30-second intervals"), window math (1,196 codes over a ~23:35–09:00 night), and the 0x49 summary's 600-minute window. Bursts flush on gap/drain-end/teardown, advance the resume cursor, and log a
    self-evident summary (hypnogram reconstructed [start → end] deep/light/rem/awake min).

  4. Phase persistence (OuraStreamMapping)
    Each code now persists at its reconstructed 30 s-spaced timestamp (distinct (deviceId, ts, kind) rows); the interim ts + index collision workaround is removed.

Validation

  • OuraProtocol 112/112, WhoopStore 256/256 (swift test); macOS app compiled locally (app-build CI is off).
  • On-device (Gen3, night of 2026-07-11→12): full hypnogram decoded — deep 88 / light 331 / REM 79 / awake 100 min over 23:32→09:30 — vs WHOOP's staging of the same night (asleep 8:18 vs 8:54; deep/REM skew within normal vendor
    disagreement). Reconstruction re-verified via a forced full re-pull.

Notes

  • Display is not wired yet: the stage timeline persists as OURA_SLEEP_PHASE events but nothing builds a sleep session from them (pure-Oura nights still show no Sleep card) — that's the follow-up, pending the question about
    reusing the docs: add a validation protocol for scoring changes #988 stage-timeline path.
  • No Android twin exists for the experimental Oura BLE path; no parity change.

pipiche38 and others added 5 commits July 11, 2026 19:10
…, 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
@ryanbr

ryanbr commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Thanks for this — the reconstruction work is well done and the decode side checks out locally (OuraProtocol 112/112, including the new HypnogramAssemblerTests). The stage-mapping correction is right: OURA_PROTOCOL.md §s4.2 already names open_oura's decode_sleep_phases as the decoder authority for the 0x4B/0x4E/0x5A tags, the "code 3 decoded at wake = awake" capture is decisive, and the on-device proportions are physiological. The 0x12 SyncTime layout fix and the burst/30s-backward assembler both look correct.

It can't land as-is, though — the "no Android twin exists" note isn't accurate, so as written it would break the cross-platform parity contract (the #1 rule in CLAUDE.md):

  1. Android decodes these same tags with the OLD mapping. android/app/src/main/java/com/noop/oura/OuraEvents.kt has OuraSleepStage { AWAKE(0), LIGHT(1), DEEP(2), REM(3) }, and OuraStreamMapping.kt folds those phases into a sleep session. Merging this Swift-only change makes iOS decode raw code 0 as deep and Android decode it as awake from byte-identical stored rows — a decoder divergence at the two extremes. The Kotlin enum needs the same deep=0, light=1, rem=2, awake=3 flip plus a twin test, in the same change.

  2. The doc still teaches the old mapping. OURA_PROTOCOL.md §6.12 still reads 0=awake, 1=light, 2=deep, 3=REM — the exact text both the old Swift path and the current Android enum inherited the bug from. It has to be corrected in the same change, or it re-spawns.

The time-axis reconstruction (assembler + 0x12 send) is a BLE-path feature where Swift is now ahead of Android; that part is fine to land Swift-first as a tracked follow-up (display isn't wired on either platform yet), but the decoder mapping above can't diverge in the meantime.

We'll fold the corrected Android OuraSleepStage + twin test + the §6.12 doc fix in alongside these Swift changes so the mapping stays byte-identical across platforms, and open a separate issue to track the Android hypnogram time-axis reconstruction. Crediting your mapping correction and the assembler design.

@tigercraft4 tigercraft4 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (external, non-blocking). The protocol fix + reconstruction read as sound and the assembler is well unit-tested. One data-integrity point worth confirming before this drives any rendered value, plus a couple of consistency nits. Note: Decoders.swift:334 carries the same stale 0=awake…3=REM doc comment but isn't in this diff, so it can't be flagged inline — worth updating in the same pass.

Comment thread Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift
Comment thread Packages/OuraProtocol/Sources/OuraProtocol/HypnogramAssembler.swift
…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.
@pipiche38

Copy link
Copy Markdown
Author

@ryanbr @tigercraft4

I have merged against the 8.7.0 and also addressed the review points in the last commit cebde9d

  • Android OuraSleepStage flipped to the validated order + golden/pin tests (byte-identical twin); §6.12 corrected at the source line. Note: Android's 0x4B is still SLEEP_SUMMARY_B (iOS routes it to the phase decoder per open_oura) — happy to fold that into the tracked Android follow-up.

  • Stale old-mapping doc lines fixed (OuraEvents/Decoders).

  • Bursts now surface hasNonMonotonicRingTimes (logged, not silently trusted); arrival order intentionally not re-sorted since envelope rts are near-identical write moments — rationale documented + tested.

  • Wall-clock fallback removed: unanchored bursts hold until the anchor and drop honestly at teardown (lossless — the cursor only advances on anchored persists, so the ring re-serves them).

  • On stored rows: confirmed no consumer renders OURA_SLEEP_PHASE yet (no sleep-session builder exists); pre-correction rows are investigation-only. Kotlin edits verified by inspection — no local JVM here; CI's testFullDebugUnitTest covers the twins.

@ryanbr I would be pleased if you could give me some hints on how you would like me to move forward, when I'll be a stage where I can push into persistent storage, see #323

From the latest commit of today (based on 8.7.0 )

What worked (this is the real validation with Gen3)

  1. The anchor + SyncTime chain is now producing REAL dates. Everything in this drain lands where life actually happened: sleep-MET dips 22:17→07:34 and 22:52→06:34, your 7/10 midday workout (12:44–13:28, MET ~5), the 7/11 walk/swim block (16:06–18:15, 19:47–21:08). No 2-day shift anymore.
  2. Two full nights reconstructed at their true dates:
    • 7/09 23:21 → 7/10 09:19 — 1196 codes, deep/light/rem/awake = 104/356/60/78 min
    • 7/10 23:11 → 7/11 06:59 — 936 codes, 64/278/48/79 min
    • plus a 104-code segment 7/11 18:00→18:52 (37 min awake — an evening couch/doze capture, correctly kept separate from the night)
  3. Idempotency proven under fire. The ring re-served the same window 5 times in this session; every pass reconstructed byte-identical bursts onto the same slots — no duplicates, no drift. And the stall guard did its job: bytes_left stuck at ~409 KB (even creeping up as live data accrues) → force-stop, cursor committed.

pipiche38 and others added 3 commits July 12, 2026 21:49
…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
@pipiche38

Copy link
Copy Markdown
Author

@tigercraft4 it would be interesting if you could you use that branch, for the 0x71 decoding issue. I have added :

  • a specific decoder
  • a log of the 0x71 record and decoded so we can cross-check it

@tigercraft4 tigercraft4 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Re-anchoring two earlier inline notes that landed as position-only.)

Comment thread Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift
Comment thread Strand/BLE/OuraLiveSource.swift
@tigercraft4

Copy link
Copy Markdown

Thanks @pipiche38 — pulled your branch (oura-hypnogram-reconstruction @ 7e1b3a3) and read the 0x71 work. The core of #287 is handled exactly right: greenIbiAmp is demoted from Tier A, gated behind allowTierB, log-only / never a stored rrInterval, and no longer routed through the 0x60 decodeIBIAmplitude. That removes the HRV-corruption path — 👍.

Honest constraint up front: I don't have an Oura ring, so I can't produce a real 0x71 capture myself. What I can do is (a) review the candidate decoder and (b) hand-decode any raw payload you paste from the fixture log and cross-check the numbers against the concurrent live-HR R-R. Two things I'd check once a capture lands:

  1. Amplitude↔IBI pairing (decodeGreenIBIAmpCandidate). amps reads 5 bytes (p[6...10]), then samples[0] is amp-only with amps[0] and the loop appends (ds[i], amps[i]) for i in 0..<5 — so amps[0] is emitted twice (the amp-only entry and the first IBI pair), and the last IBI never gets a distinct amplitude. §6.2 (per Oura 0x71 (green IBI+amp) decoded with the 0x60 layout → corrupted HRV #287) describes 6 amplitudes for 5 IBIs. If that's right, the pairing is likely off by one — the 5 IBIs should pair with amps[1...5] over 6 amplitudes, not reuse amps[0]. The fixture log will disambiguate: for each record, does the amp count == IBI count + 1? That's the single thing the capture should confirm before promotion.

  2. 0x44 is still on the 0x60 decoder. OuraDriver .ibi (0x44) still routes through decodeIBIAmplitude (OuraDriver.swift:277-279, "shares the bit-packed layout family"). Oura 0x71 (green IBI+amp) decoded with the 0x60 layout → corrupted HRV #287 flagged 0x44 with the same risk as 0x71 — if its layout isn't confirmed either, it's worth the same TIER-B / log-only treatment until a capture proves it, rather than emitting possibly-wrong IBIs into HRV.

If you paste one or two logged 0x71 records here (raw hex + the anchored time + the decoder's output + the concurrent live-HR R-R for that window), I'll hand-decode them against both the candidate layout and the ringverse p_green_ibi_and_amp reference and report whether the numbers line up — happy to help validate before it's promoted off TIER-B.

pipiche38 and others added 3 commits July 13, 2026 08:59
…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
@pipiche38

Copy link
Copy Markdown
Author

Today's session, summed up

Every layer of the stack proved itself in one run:

  1. 0x13 anchor — deterministic anchor at connect, unit self-resolved as ticks, all records dated from the first batch, cursor advanced at last.
  2. 0x49 window — 23:32:24 → 08:08:24 vs your reported 23:34 → 08:03: the ringverse decode is ground-truth-validated.
  3. The last axis bug found and fixed: the reconstruction landed at 00:11 → 08:51 because the burst end was anchored at the SleepNet write moment, which today trailed your actual wake by 43 minutes (yesterday it was only 10,
    which is why it slipped through the WHOOP comparison). The burst end now anchors at the 0x49's true sleep end (event − end_offset), matched to the same finalization by ring-time proximity, sanity-gated, with the write moment as
    fallback. Expected axis for last night after a re-pull: ~23:28 → 08:08 — and the +43 min of phantom "awake" tail disappears from the stage totals too.

@pipiche38

Copy link
Copy Markdown
Author

@ryanbr working on the Android twin ... will keep you posted

pipiche38 and others added 2 commits July 13, 2026 12:34
…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
@pipiche38

Copy link
Copy Markdown
Author

@ryanbr Android twin ported in cae2847 — the full stack (0x11/#91 fix, one-packet framing, u64 SyncTime + 0x13 anchor, drain core, hypnogram reconstruction incl. the 0x49 end refinement, 0x4B
reclassification, 0x71 candidate) with JVM twin tests mirroring the Swift suites; all Oura test suites green under testFullDebugUnitTest. The one honest remaining gap is on-device validation of the Android BLE session choreography — I don't have an Android device paired to a ring; same validation ask the Swift side had before its on-device passes."

@pipiche38

pipiche38 commented Jul 14, 2026

Copy link
Copy Markdown
Author

This is the cleanest night-sync yet — every mechanism we built this week fired correctly on the first connect, and the reconstructed night matches your ground truth almost exactly.

Against your 23:31 → 7:38 night:

  • Reconstructed window: 23:30:30 → 07:44:30. Start is dead-on (within the 30 s code resolution). End is +6.5 min past your 7:38 — and the tail of the burst is heavy on awake codes, so 07:44 likely marks the end of the sleep
    period (including the final lying-awake stretch) while 7:38 is your last wake. That's the same ~5 min end-bias direction as the 08:08-vs-08:03 night, so it looks like a consistent semantic offset of the 0x49 end field, not
    noise.
  • Staging: 988 codes over 8h14m — 56 deep / 292 light / 64 REM / 81 awake min (~6h52m asleep). Worth eyeballing against WHOOP's stage breakdown when its cloud data lands.
  • The separate little burst (19:02→19:28 on 7/13, 16 deep/10 awake min) is the ring detecting an evening rest/nap — the assembler correctly split it from the night instead of gluing 1040 codes into one blob (19×52 night + 1×52
    nap).

Pipeline health, all green:

  • 0x13 anchor adopted instantly ("no 0x42 needed") — second consecutive session, the anchorless-morning problem is gone.
  • Drain: ~60 batches, cursor strictly monotonic 5190567 → 5675733, bytes_left counted down 0x00049865 → 0, zero re-serves, honest history caught up. ~2 minutes for ~13.5 h of backlog.
  • 0x49 window candidate logged and applied (event −565/−71 min), refinement fired exactly once.
  • No 0x71 green IBI+amp — still zero captures ever, consistent with the "never emitted on this Gen3 firmware" conclusion.
  • Activity: first fully clean day pair with no duplicate inflation — 7/13: 245 samples, meanMET 1.40; 7/14 (overnight): meanMET 1.03, activeMin 0, exactly what a sleeping night should look like.
NOOP strap log - macOS
App: 8.7.0
macOS: Version 26.5.2 (Build 25F84)
----------------------------------------
[08:55:15] Oura: ring <device> not cached yet - scanning to find it
[08:55:15] Oura: scanning for an Oura ring (service <device>)
[08:55:15] Oura: Bluetooth not powered on (state=0) - scan deferred until ready
Read spine re-pointed to active device after registry change (#814).
[08:55:15] Central state: 5 (5 = poweredOn)
[08:55:15] Scanning for WHOOP 5.0 / MG…
sleep day=2026-07-14 totalSleepMin=nil matched=0 source=computed
hrv day=2026-07-14 window=whole avgHrv=nil
sleep day=2026-07-13 totalSleepMin=nil matched=0 source=imported:whoop
hrv day=2026-07-13 window=whole avgHrv=nil
sleep day=2026-07-12 totalSleepMin=nil matched=0 source=imported:whoop
hrv day=2026-07-12 window=whole avgHrv=nil
sleep day=2026-07-11 totalSleepMin=nil matched=0 source=imported:whoop
hrv day=2026-07-11 window=whole avgHrv=nil
sleep day=2026-07-10 totalSleepMin=nil matched=0 source=imported:whoop
hrv day=2026-07-10 window=whole avgHrv=nil
sleep day=2026-07-09 totalSleepMin=nil matched=0 source=imported:whoop
hrv day=2026-07-09 window=whole avgHrv=nil
sleep day=2026-07-08 totalSleepMin=nil matched=0 source=imported:whoop
hrv day=2026-07-08 window=whole avgHrv=nil
sleep day=2026-07-07 totalSleepMin=nil matched=0 source=imported:whoop
hrv day=2026-07-07 window=whole avgHrv=nil
[08:55:24] Oura: found Oura Ring Gen3 (<device>) rssi -64
[08:55:24] Oura: connecting to <device>
[08:55:24] No WHOOP 5.0 / MG found yet — trying WHOOP 4.0
[08:55:24] Scanning for WHOOP 4.0…
[08:55:25] Oura: connected - discovering services
[08:55:30] Oura: Oura service found - discovering characteristics
[08:55:30] Oura: write characteristic found
[08:55:30] Oura: notify characteristic found - enabling notifications
[08:55:30] Oura: notifications enabled (isNotifying=true) - beginning auth
[08:55:30] Oura: -> notify_all
[08:55:30] Oura: -> get_nonce
[08:55:30] Oura: auth nonce received - submitting proof
[08:55:30] Oura: -> submit_proof
[08:55:30] Oura: auth OK - enabling live HR
[08:55:30] Oura: -> dhr_read
[08:55:30] Oura: -> dhr_enable
[08:55:30] Oura: -> dhr_subscribe
[08:55:30] Oura: live-HR enabled - streaming HR / IBI
[08:55:30] Oura: -> sync_time
[08:55:30] Oura: fetching history from cursor 5190567 (no anchor yet) [cursor-fix]
[08:55:30] Oura: -> flush_buffer
[08:55:30] Oura: -> get_events
[08:55:30] Oura: -> get_battery
[08:55:30] Oura: UTC anchor from SyncTime response (0x13) - device rt 5674786 [ticks, raw 0x00569722, status 0] = now; no 0x42 needed this session
[08:55:30] Oura: battery 49%
[08:55:30] Oura: sleep-phase record [2026-07-13 19:28:28] codes=52 deep/light/rem/awake=32/0/0/20
[08:55:30] Oura: Tier-B sleep_summary seen (tag 0x4f) - raw: 78 24 d6 01 0b 00 00 00 02 00 00
[08:55:30] Oura: first skin temp decoded (last night) - 33.53C
[08:55:30] Oura: dropping first live HR 63 bpm (settling sample)
[08:55:30] Oura: receiving live data - first HR 69 bpm
[08:55:30] Oura: activity (Tier-B) [2026-07-13 19:36:29] state=61 met=[1.7, 1.4, 1.5, 1.5, 1.7, 1.6, 1.9, 2.2, 2.4]
[08:55:30] Oura: GetEvents summary raw body (8B) - ff 09 65 98 04 00 03 00
[08:55:32] Oura: history batch done - continuing from cursor 5198628 [2026-07-13 19:41:54]
[08:55:32] Oura: -> get_events
[08:55:32] Oura: activity (Tier-B) [2026-07-13 19:49:29] state=21 met=[1.4, 1.6, 1.7, 1.6, 1.5, 1.4, 1.4, 1.3, 1.6, 1.3, 1.7, 1.7, 1.5]
[08:55:32] Oura: GetEvents summary raw body (8B) - ff 3a 2b 86 04 00 03 00
[08:55:34] Oura: history batch done - continuing from cursor 5207817 [2026-07-13 19:57:13]
[08:55:34] Oura: -> get_events
[08:55:34] Oura: activity (Tier-B) [2026-07-13 20:02:29] state=28 met=[1.5, 1.7, 1.9, 1.5, 1.5, 1.6, 1.3, 1.5, 1.9, 1.7, 1.4, 1.7, 1.4]
[08:55:34] Oura: activity (Tier-B) [2026-07-13 20:12:29] state=52 met=[1.1, 1.2, 1.3, 1.2, 1.4, 1.5, 1.3, 1.5, 1.6, 3.6]
[08:55:34] Oura: activity (Tier-B) [2026-07-13 20:14:29] state=94 met=[3.2, 3.5]
[08:55:34] Oura: activity (Tier-B) [2026-07-13 20:16:29] state=54 met=[1.9, 3.3]
[08:55:34] Oura: GetEvents summary raw body (8B) - ff 00 26 76 04 00 03 00
[08:55:36] Oura: history batch done - continuing from cursor 5219825 [2026-07-13 20:17:13]
[08:55:36] Oura: -> get_events
[08:55:36] Oura: activity (Tier-B) [2026-07-13 20:21:29] state=68 met=[2.5, 1.6, 1.5, 2.3, 2.1]
[08:55:36] Oura: GetEvents summary raw body (8B) - ff 00 ba 63 04 00 03 00
[08:55:38] Oura: history batch done - continuing from cursor 5228762 [2026-07-13 20:32:07]
[08:55:38] Oura: -> get_events
[08:55:38] Oura: activity (Tier-B) [2026-07-13 20:34:29] state=48 met=[1.8, 2.4, 1.2, 1.3, 1.2, 1.2, 1.6, 1.5, 1.3, 1.3, 1.2, 1.2, 1.2]
[08:55:38] Oura: activity (Tier-B) [2026-07-13 20:41:29] state=60 met=[1.1, 0.9, 1.6, 2.6, 1.1, 1.1, 2.6]
[08:55:38] Oura: activity (Tier-B) [2026-07-13 20:47:29] state=51 met=[2.0, 1.7, 1.7, 1.6, 1.9, 1.9]
[08:55:38] Oura: GetEvents summary raw body (8B) - ff 00 47 51 04 00 03 00
[08:55:40] Oura: history batch done - continuing from cursor 5239237 [2026-07-13 20:49:35]
[08:55:40] Oura: -> get_events
[08:55:40] Oura: activity (Tier-B) [2026-07-13 21:00:29] state=45 met=[2.4, 1.6, 1.2, 1.7, 1.9, 1.4, 1.3, 1.2, 1.3, 1.4, 1.3, 1.2, 1.2]
[08:55:40] Oura: GetEvents summary raw body (8B) - ff 00 1a 3f 04 00 03 00
[08:55:42] Oura: history batch done - continuing from cursor 5249874 [2026-07-13 21:07:18]
[08:55:42] Oura: -> get_events
[08:55:42] Oura: activity (Tier-B) [2026-07-13 21:13:29] state=30 met=[1.3, 1.3, 1.2, 1.3, 1.6, 2.1, 1.7, 1.3, 1.2, 1.2, 1.2, 1.2, 1.2]
[08:55:42] Oura: GetEvents summary raw body (8B) - ff 00 a5 2c 04 00 03 00
[08:55:44] Oura: history batch done - continuing from cursor 5260386 [2026-07-13 21:24:50]
[08:55:44] Oura: -> get_events
[08:55:44] Oura: activity (Tier-B) [2026-07-13 21:26:29] state=0 met=[1.2, 1.3, 1.3, 1.5, 1.3, 1.2, 1.2, 1.2, 1.2, 1.2, 1.2, 1.2, 1.2]
[08:55:44] Oura: activity (Tier-B) [2026-07-13 21:39:29] state=0 met=[1.2, 1.2, 1.1, 1.2, 1.2, 1.2, 1.2, 1.2, 1.3, 1.2, 1.2, 1.2, 1.2]
[08:55:44] Oura: GetEvents summary raw body (8B) - ff 00 2a 1a 04 00 03 00
[08:55:45] Oura: history batch done - continuing from cursor 5271579 [2026-07-13 21:43:29]
[08:55:45] Oura: -> get_events
[08:55:46] Oura: activity (Tier-B) [2026-07-13 21:52:29] state=0 met=[1.2, 1.2, 1.2, 1.2, 1.1, 1.0, 0.9, 1.2, 1.0, 1.1, 1.1, 1.2, 1.2]
[08:55:46] Oura: GetEvents summary raw body (8B) - ff 00 d2 07 04 00 03 00
[08:55:47] Oura: history batch done - continuing from cursor 5280867 [2026-07-13 21:58:58]
[08:55:47] Oura: -> get_events
[08:55:48] Oura: GetEvents summary raw body (8B) - ff 00 bf f4 03 00 03 00
[08:55:49] Oura: history batch done - continuing from cursor 5287167 [2026-07-13 22:09:28]
[08:55:49] Oura: -> get_events
[08:55:50] Oura: activity (Tier-B) [2026-07-13 22:23:29] state=14 met=[1.1, 1.5, 1.8, 1.8, 1.4, 1.3, 1.4, 1.4, 1.6, 1.6, 1.4, 1.5, 1.3]
[08:55:50] Oura: GetEvents summary raw body (8B) - ff 00 1e e2 03 00 03 00
[08:55:51] Oura: history batch done - continuing from cursor 5298303 [2026-07-13 22:28:01]
[08:55:51] Oura: -> get_events
[08:55:52] Oura: activity (Tier-B) [2026-07-13 22:36:29] state=25 met=[1.2, 1.6, 2.5, 1.4, 1.3, 1.2, 1.2, 1.3, 1.2, 1.2, 1.2, 1.3, 1.2]
[08:55:52] Oura: GetEvents summary raw body (8B) - ff 00 4b cf 03 00 03 00
[08:55:53] Oura: history batch done - continuing from cursor 5308111 [2026-07-13 22:44:22]
[08:55:53] Oura: -> get_events
[08:55:54] Oura: activity (Tier-B) [2026-07-13 22:49:29] state=0 met=[1.3, 1.2, 1.4, 1.3, 1.2, 1.2, 1.3, 1.4, 1.2, 1.2, 1.2, 1.2, 1.2]
[08:55:54] Oura: GetEvents summary raw body (8B) - ff 00 72 bc 03 00 03 00
[08:55:55] Oura: history batch done - continuing from cursor 5314609 [2026-07-13 22:55:12]
[08:55:55] Oura: -> get_events
[08:55:56] Oura: activity (Tier-B) [2026-07-13 23:02:29] state=0 met=[1.2, 1.2, 1.2, 1.2, 1.2, 0.9, 0.9, 1.0, 1.0, 0.9, 0.9, 1.0, 1.0]
[08:55:56] Oura: GetEvents summary raw body (8B) - ff 00 df a8 03 00 03 00
[08:55:57] Oura: history batch done - continuing from cursor 5319105 [2026-07-13 23:02:41]
[08:55:57] Oura: -> get_events
[08:55:58] Oura: activity (Tier-B) [2026-07-13 23:11:29] state=59 met=[1.2, 1.4, 1.6, 1.4, 1.3, 1.2, 1.2, 2.1, 2.9]
[08:55:58] Oura: GetEvents summary raw body (8B) - ff 00 73 96 03 00 03 00
[08:55:59] Oura: history batch done - continuing from cursor 5331095 [2026-07-13 23:22:40]
[08:55:59] Oura: -> get_events
[08:55:59] Oura: activity (Tier-B) [2026-07-13 23:24:29] state=14 met=[1.3, 1.9, 1.2, 1.2, 1.2, 1.2, 1.2, 1.3, 1.2, 1.3, 1.3, 1.2, 1.2]
[08:56:00] Oura: GetEvents summary raw body (8B) - ff 00 47 83 03 00 03 00
[08:56:01] Oura: history batch done - continuing from cursor 5339416 [2026-07-13 23:36:33]
[08:56:01] Oura: -> get_events
[08:56:01] Oura: activity (Tier-B) [2026-07-13 23:37:29] state=14 met=[1.1, 1.1, 1.2, 1.2, 1.7, 1.6, 1.6, 1.2, 1.2, 1.1, 0.9, 0.9, 0.9]
[08:56:02] Oura: GetEvents summary raw body (8B) - ff 00 09 6f 03 00 03 00
[08:56:03] Oura: history batch done - continuing from cursor 5344218 [2026-07-13 23:44:33]
[08:56:03] Oura: -> get_events
[08:56:03] Oura: activity (Tier-B) [2026-07-13 23:50:29] state=0 met=[1.1, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9]
[08:56:03] Oura: GetEvents summary raw body (8B) - ff 00 e3 5a 03 00 03 00
[08:56:05] Oura: history batch done - continuing from cursor 5350535 [2026-07-13 23:55:04]
[08:56:05] Oura: -> get_events
[08:56:05] Oura: GetEvents summary raw body (8B) - ff 00 22 47 03 00 03 00
[08:56:07] Oura: history batch done - continuing from cursor 5357953 [2026-07-14 00:07:26]
[08:56:07] Oura: -> get_events
[08:56:07] Oura: activity (Tier-B) [2026-07-14 00:17:29] state=0 met=[1.1, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9]
[08:56:07] Oura: GetEvents summary raw body (8B) - ff 00 13 33 03 00 03 00
[08:56:09] Oura: history batch done - continuing from cursor 5365391 [2026-07-14 00:19:50]
[08:56:09] Oura: -> get_events
[08:56:09] Oura: GetEvents summary raw body (8B) - ff 00 15 1f 03 00 03 00
[08:56:11] Oura: history batch done - continuing from cursor 5372926 [2026-07-14 00:32:24]
[08:56:11] Oura: -> get_events
[08:56:11] Oura: GetEvents summary raw body (8B) - ff 00 16 0b 03 00 03 00
[08:56:13] Oura: history batch done - continuing from cursor 5379825 [2026-07-14 00:43:53]
[08:56:13] Oura: -> get_events
[08:56:13] Oura: activity (Tier-B) [2026-07-14 00:44:29] state=0 met=[1.1, 0.9, 1.0, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9]
[08:56:13] Oura: GetEvents summary raw body (8B) - ff 00 71 f7 02 00 03 00
[08:56:15] Oura: history batch done - continuing from cursor 5386812 [2026-07-14 00:55:32]
[08:56:15] Oura: -> get_events
[08:56:15] Oura: GetEvents summary raw body (8B) - ff 00 7a e3 02 00 03 00
[08:56:17] Oura: history batch done - continuing from cursor 5395192 [2026-07-14 01:09:30]
[08:56:17] Oura: -> get_events
[08:56:17] Oura: GetEvents summary raw body (8B) - ff 00 7f cf 02 00 03 00
[08:56:18] Oura: history batch done - continuing from cursor 5402918 [2026-07-14 01:22:23]
[08:56:18] Oura: -> get_events
[08:56:19] Oura: GetEvents summary raw body (8B) - ff 00 54 bc 02 00 03 00
[08:56:20] Oura: history batch done - continuing from cursor 5408783 [2026-07-14 01:32:09]
[08:56:20] Oura: -> get_events
[08:56:21] Oura: activity (Tier-B) [2026-07-14 01:37:29] state=0 met=[1.1, 1.0, 1.0, 0.9, 0.9, 1.0, 0.9, 1.1, 0.9, 0.9, 0.9, 0.9, 0.9]
[08:56:21] Oura: GetEvents summary raw body (8B) - ff 00 6b a8 02 00 03 00
[08:56:22] Oura: history batch done - continuing from cursor 5416132 [2026-07-14 01:44:24]
[08:56:22] Oura: -> get_events
[08:56:23] Oura: activity (Tier-B) [2026-07-14 01:55:29] state=0 met=[1.1, 0.9, 0.9, 0.9, 1.1, 0.9, 1.2, 1.2, 0.9, 0.9, 0.9, 0.9, 0.9]
[08:56:23] Oura: GetEvents summary raw body (8B) - ff 00 05 95 02 00 03 00
[08:56:24] Oura: history batch done - continuing from cursor 5423416 [2026-07-14 01:56:33]
[08:56:24] Oura: -> get_events
[08:56:25] Oura: GetEvents summary raw body (8B) - ff 00 54 81 02 00 03 00
[08:56:26] Oura: history batch done - continuing from cursor 5431245 [2026-07-14 02:09:35]
[08:56:26] Oura: -> get_events
[08:56:26] Oura: activity (Tier-B) [2026-07-14 02:18:29] state=0 met=[1.1, 0.9, 0.9, 0.9, 0.9, 0.9, 1.1, 0.9, 0.9, 0.9, 0.9, 1.3, 1.2]
[08:56:27] Oura: GetEvents summary raw body (8B) - ff 00 09 6e 02 00 03 00
[08:56:28] Oura: history batch done - continuing from cursor 5438117 [2026-07-14 02:21:03]
[08:56:28] Oura: -> get_events
[08:56:28] Oura: GetEvents summary raw body (8B) - ff 00 f2 5a 02 00 03 00
[08:56:30] Oura: history batch done - continuing from cursor 5443928 [2026-07-14 02:30:44]
[08:56:30] Oura: -> get_events
[08:56:30] Oura: activity (Tier-B) [2026-07-14 02:31:29] state=0 met=[1.3, 0.9, 1.1, 1.0, 1.0, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 1.0]
[08:56:30] Oura: GetEvents summary raw body (8B) - ff 00 da 47 02 00 03 00
[08:56:32] Oura: history batch done - continuing from cursor 5451000 [2026-07-14 02:42:31]
[08:56:32] Oura: -> get_events
[08:56:32] Oura: activity (Tier-B) [2026-07-14 02:45:29] state=0 met=[1.2, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 1.1, 0.9, 1.0, 0.9, 0.9, 0.9]
[08:56:32] Oura: GetEvents summary raw body (8B) - ff 00 98 34 02 00 03 00
[08:56:33] Oura: history batch done - continuing from cursor 5457246 [2026-07-14 02:52:56]
[08:56:33] Oura: -> get_events
[08:56:34] Oura: activity (Tier-B) [2026-07-14 02:58:29] state=7 met=[1.1, 1.0, 1.0, 1.7, 0.9, 0.9, 1.0, 1.3, 0.9, 0.9, 0.9, 0.9, 0.9]
[08:56:34] Oura: GetEvents summary raw body (8B) - ff 00 66 20 02 00 03 00
[08:56:35] Oura: history batch done - continuing from cursor 5463222 [2026-07-14 03:02:53]
[08:56:35] Oura: -> get_events
[08:56:36] Oura: GetEvents summary raw body (8B) - ff 00 57 0c 02 00 03 00
[08:56:37] Oura: history batch done - continuing from cursor 5471152 [2026-07-14 03:16:06]
[08:56:37] Oura: -> get_events
[08:56:37] Oura: GetEvents summary raw body (8B) - ff 00 47 f8 01 00 03 00
[08:56:39] Oura: history batch done - continuing from cursor 5478265 [2026-07-14 03:27:57]
[08:56:39] Oura: -> get_events
[08:56:39] Oura: GetEvents summary raw body (8B) - ff 00 73 e4 01 00 03 00
[08:56:41] Oura: history batch done - continuing from cursor 5484860 [2026-07-14 03:38:57]
[08:56:41] Oura: -> get_events
[08:56:41] Oura: activity (Tier-B) [2026-07-14 03:40:29] state=0 met=[1.1, 0.9, 1.1, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 1.1, 0.9, 1.0, 1.3]
[08:56:41] Oura: GetEvents summary raw body (8B) - ff 00 55 d1 01 00 03 00
[08:56:43] Oura: history batch done - continuing from cursor 5491012 [2026-07-14 03:49:12]
[08:56:43] Oura: -> get_events
[08:56:43] Oura: activity (Tier-B) [2026-07-14 03:53:29] state=0 met=[1.1, 0.9, 0.9, 1.0, 1.1, 0.9, 0.9, 1.2, 0.9, 0.9, 0.9, 0.9, 0.9]
[08:56:43] Oura: GetEvents summary raw body (8B) - ff 00 85 bd 01 00 03 00
[08:56:44] Oura: history batch done - continuing from cursor 5498345 [2026-07-14 04:01:25]
[08:56:44] Oura: -> get_events
[08:56:45] Oura: activity (Tier-B) [2026-07-14 04:10:29] state=0 met=[1.2, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9]
[08:56:45] Oura: GetEvents summary raw body (8B) - ff 00 a9 a9 01 00 03 00
[08:56:46] Oura: history batch done - continuing from cursor 5505974 [2026-07-14 04:14:08]
[08:56:46] Oura: -> get_events
[08:56:47] Oura: GetEvents summary raw body (8B) - ff 00 ad 95 01 00 03 00
[08:56:48] Oura: history batch done - continuing from cursor 5514373 [2026-07-14 04:28:08]
[08:56:48] Oura: -> get_events
[08:56:48] Oura: activity (Tier-B) [2026-07-14 04:36:29] state=0 met=[1.1, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9]
[08:56:49] Oura: GetEvents summary raw body (8B) - ff 00 bd 81 01 00 03 00
[08:56:50] Oura: history batch done - continuing from cursor 5522108 [2026-07-14 04:41:02]
[08:56:50] Oura: -> get_events
[08:56:50] Oura: activity (Tier-B) [2026-07-14 04:51:29] state=0 met=[1.1, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 1.1, 0.9, 0.9, 0.9]
[08:56:50] Oura: GetEvents summary raw body (8B) - ff 00 b5 6d 01 00 03 00
[08:56:52] Oura: history batch done - continuing from cursor 5528396 [2026-07-14 04:51:31]
[08:56:52] Oura: -> get_events
[08:56:52] Oura: GetEvents summary raw body (8B) - ff 00 15 5a 01 00 03 00
[08:56:54] Oura: history batch done - continuing from cursor 5535935 [2026-07-14 05:04:04]
[08:56:54] Oura: -> get_events
[08:56:54] Oura: GetEvents summary raw body (8B) - ff 00 77 46 01 00 03 00
[08:56:56] Oura: history batch done - continuing from cursor 5543335 [2026-07-14 05:16:24]
[08:56:56] Oura: -> get_events
[08:56:56] Oura: activity (Tier-B) [2026-07-14 05:19:29] state=0 met=[1.1, 1.0, 0.9, 1.1, 0.9, 0.9, 0.9, 0.9, 0.9, 1.0, 0.9, 0.9, 1.0]
[08:56:56] Oura: GetEvents summary raw body (8B) - ff 00 fc 32 01 00 03 00
[08:56:58] Oura: history batch done - continuing from cursor 5550507 [2026-07-14 05:28:22]
[08:56:58] Oura: -> get_events
[08:56:58] Oura: activity (Tier-B) [2026-07-14 05:33:29] state=0 met=[1.2, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 1.1, 0.9, 0.9, 1.1, 1.1]
[08:56:58] Oura: GetEvents summary raw body (8B) - ff 00 71 1f 01 00 03 00
[08:56:59] Oura: history batch done - continuing from cursor 5556607 [2026-07-14 05:38:32]
[08:56:59] Oura: -> get_events
[08:57:00] Oura: activity (Tier-B) [2026-07-14 05:46:29] state=0 met=[1.1, 1.0, 0.9, 0.9, 0.9, 1.2, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9]
[08:57:00] Oura: GetEvents summary raw body (8B) - ff 00 b1 0b 01 00 03 00
[08:57:01] Oura: history batch done - continuing from cursor 5564037 [2026-07-14 05:50:55]
[08:57:01] Oura: -> get_events
[08:57:02] Oura: activity (Tier-B) [2026-07-14 05:59:29] state=0 met=[1.1, 1.2, 1.1, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9]
[08:57:02] Oura: GetEvents summary raw body (8B) - ff 00 f6 f7 00 00 03 00
[08:57:03] Oura: history batch done - continuing from cursor 5572177 [2026-07-14 06:04:29]
[08:57:03] Oura: -> get_events
[08:57:04] Oura: GetEvents summary raw body (8B) - ff 00 6b e4 00 00 03 00
[08:57:06] Oura: history batch done - continuing from cursor 5579926 [2026-07-14 06:17:24]
[08:57:06] Oura: -> get_events
[08:57:06] Oura: activity (Tier-B) [2026-07-14 06:20:29] state=0 met=[1.1, 0.9, 0.9, 0.9, 0.9, 0.9, 1.0, 1.1, 0.9, 0.9, 0.9, 0.9, 0.9]
[08:57:06] Oura: GetEvents summary raw body (8B) - ff 00 98 d0 00 00 03 00
[08:57:08] Oura: history batch done - continuing from cursor 5586312 [2026-07-14 06:28:02]
[08:57:08] Oura: -> get_events
[08:57:08] Oura: activity (Tier-B) [2026-07-14 06:33:29] state=0 met=[1.2, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 1.2]
[08:57:08] Oura: GetEvents summary raw body (8B) - ff 00 f8 bc 00 00 03 00
[08:57:10] Oura: history batch done - continuing from cursor 5593727 [2026-07-14 06:40:24]
[08:57:10] Oura: -> get_events
[08:57:10] Oura: GetEvents summary raw body (8B) - ff 00 86 a9 00 00 03 00
[08:57:12] Oura: history batch done - continuing from cursor 5599657 [2026-07-14 06:50:17]
[08:57:12] Oura: -> get_events
[08:57:12] Oura: activity (Tier-B) [2026-07-14 06:53:29] state=0 met=[1.1, 0.9, 0.9, 0.9, 1.1, 0.9, 0.9, 0.9, 1.1, 1.1, 0.9, 1.1, 0.9]
[08:57:12] Oura: GetEvents summary raw body (8B) - ff 00 f4 95 00 00 03 00
[08:57:14] Oura: history batch done - continuing from cursor 5605484 [2026-07-14 06:59:59]
[08:57:14] Oura: -> get_events
[08:57:14] Oura: activity (Tier-B) [2026-07-14 07:06:29] state=0 met=[1.1, 1.0, 1.1, 0.9, 0.9, 0.9, 0.9, 1.1, 0.9, 0.9, 0.9, 0.9, 1.2]
[08:57:14] Oura: GetEvents summary raw body (8B) - ff 00 c8 82 00 00 03 00
[08:57:16] Oura: history batch done - continuing from cursor 5610935 [2026-07-14 07:09:04]
[08:57:16] Oura: -> get_events
[08:57:16] Oura: activity (Tier-B) [2026-07-14 07:21:29] state=0 met=[1.2, 0.9, 0.9, 0.9, 1.1, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9]
[08:57:16] Oura: GetEvents summary raw body (8B) - ff 00 dd 6e 00 00 03 00
[08:57:17] Oura: history batch done - continuing from cursor 5619175 [2026-07-14 07:22:48]
[08:57:17] Oura: -> get_events
[08:57:18] Oura: GetEvents summary raw body (8B) - ff 00 27 5b 00 00 03 00
[08:57:19] Oura: history batch done - continuing from cursor 5626554 [2026-07-14 07:35:06]
[08:57:19] Oura: -> get_events
[08:57:19] Oura: activity (Tier-B) [2026-07-14 07:39:29] state=0 met=[1.1, 0.9, 0.9, 0.9, 0.9, 1.1, 1.0, 0.9, 0.9, 0.9, 1.0, 0.9, 1.2]
[08:57:20] Oura: GetEvents summary raw body (8B) - ff 00 d3 47 00 00 03 00
[08:57:21] Oura: history batch done - continuing from cursor 5632198 [2026-07-14 07:44:31]
[08:57:21] Oura: -> get_events
[08:57:21] Oura: activity (Tier-B) [2026-07-14 07:56:29] state=47 met=[1.3, 1.2, 1.0, 2.1, 2.7, 1.6, 1.2, 1.2, 1.2, 1.2, 1.1, 1.5, 0.9]
[08:57:22] Oura: activity (Tier-B) [2026-07-14 08:06:29] state=63 met=[1.2, 1.1, 1.2, 1.0, 1.0, 1.3, 1.5, 2.0, 2.8, 1.9]
[08:57:22] Oura: GetEvents summary raw body (8B) - ff 00 6e 35 00 00 03 00
[08:57:23] Oura: history batch done - continuing from cursor 5646435 [2026-07-14 08:08:14]
[08:57:23] Oura: -> get_events
[08:57:23] Oura: activity (Tier-B) [2026-07-14 08:11:29] state=59 met=[1.9, 1.8, 1.9, 2.1, 1.9]
[08:57:23] Oura: activity (Tier-B) [2026-07-14 08:24:29] state=7 met=[1.5, 1.4, 1.3, 1.4, 1.4, 1.3, 1.4, 1.2, 1.3, 1.2, 1.3, 1.5, 1.3]
[08:57:23] Oura: GetEvents summary raw body (8B) - ff 00 a5 22 00 00 03 00
[08:57:25] Oura: history batch done - continuing from cursor 5656184 [2026-07-14 08:24:29]
[08:57:25] Oura: -> get_events
[08:57:25] Oura: activity (Tier-B) [2026-07-14 08:37:29] state=0 met=[1.3, 1.2, 1.3, 1.2, 1.2, 1.3, 1.3, 1.3, 1.2, 1.2, 1.2, 1.3, 1.2]
[08:57:25] Oura: GetEvents summary raw body (8B) - ff 00 68 10 00 00 03 00
[08:57:27] Oura: history batch done - continuing from cursor 5667841 [2026-07-14 08:43:55]
[08:57:27] Oura: -> get_events
[08:57:27] Oura: activity (Tier-B) [2026-07-14 08:50:29] state=0 met=[1.2, 1.2, 1.0, 0.9, 1.0, 1.1, 1.0, 1.0, 1.2, 1.0, 1.0, 1.0, 0.9]
[08:57:27] Oura: 0x49 sleep window candidate [ringverse] 2026-07-13 23:30:30 -> 2026-07-14 07:44:30 (event-565/-71 min)
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:33] codes=52 deep/light/rem/awake=3/14/15/20
[08:57:27] Oura: hypnogram reconstructed [2026-07-13 19:02:28 → 2026-07-13 19:28:28, anchored] codes=52 deep/light/rem/awake=16/0/0/10 min
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:33] codes=52 deep/light/rem/awake=0/15/37/0
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:33] codes=52 deep/light/rem/awake=0/12/35/5
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:33] codes=52 deep/light/rem/awake=0/52/0/0
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:33] codes=52 deep/light/rem/awake=1/51/0/0
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:33] codes=52 deep/light/rem/awake=0/52/0/0
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:34] codes=52 deep/light/rem/awake=0/36/0/16
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:34] codes=52 deep/light/rem/awake=2/39/0/11
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:34] codes=52 deep/light/rem/awake=36/14/2/0
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:34] codes=52 deep/light/rem/awake=0/47/2/3
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:34] codes=52 deep/light/rem/awake=18/34/0/0
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:34] codes=52 deep/light/rem/awake=32/0/9/11
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:34] codes=52 deep/light/rem/awake=8/22/0/22
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:34] codes=52 deep/light/rem/awake=0/45/3/4
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:34] codes=52 deep/light/rem/awake=0/46/0/6
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:34] codes=52 deep/light/rem/awake=13/23/6/10
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:35] codes=52 deep/light/rem/awake=0/40/8/4
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:35] codes=52 deep/light/rem/awake=0/18/1/33
[08:57:27] Oura: sleep-phase record [2026-07-14 08:55:35] codes=52 deep/light/rem/awake=0/24/11/17
[08:57:27] Oura: GetEvents summary raw body (8B) - e0 00 00 00 00 00 03 00
[08:57:27] Oura: hypnogram burst end refined by 0x49 - SleepNet write 2026-07-14 08:55:35 → true sleep end 2026-07-14 07:44:30 (event-71 min)
[08:57:27] Oura: hypnogram reconstructed [2026-07-13 23:30:30 → 2026-07-14 07:44:30, anchored] codes=988 deep/light/rem/awake=56/292/64/81 min
[08:57:27] Oura: history caught up (bytes_left 0) - resume cursor advanced to 5675733 [2026-07-14 08:57:04]
[08:57:27] Oura: sleep-phase cadence self-check - median 931.2s/code over 1 gaps
[08:57:27] Oura: activity cadence self-check - median 60.0s/sample over 51 gaps (assumed 60s)
[08:57:27] Oura: activity estimate day=2026-07-13 samples=245 meanMET=1.40 maxMET=3.6 metMin=342.7 activeMin=4.0 [assumed 60s/sample, Tier-B est]
[08:57:27] Oura: activity estimate day=2026-07-14 samples=366 meanMET=1.03 maxMET=2.8 metMin=375.8 activeMin=0.0 [assumed 60s/sample, Tier-B est]
[08:57:30] Oura: -> dhr_enable
[08:57:30] Oura: -> dhr_subscribe
[08:57:45] Oura: -> dhr_enable
[08:57:45] Oura: -> dhr_subscribe
[08:58:00] Oura: -> dhr_enable
[08:58:00] Oura: -> dhr_subscribe

@pipiche38

pipiche38 commented Jul 14, 2026

Copy link
Copy Markdown
Author

I'll be starting working on moving it to Tier-A based on thsi approach as we don't have any HR/RR
on a different branch for a further PR.

Reuse the stage-timeline rows path from #988 / the HC import (#240): the Health Connect import already ingests third-party-staged sleep with stage timelines. Could a BLE source feed the same structures (as source: oura-ring / provided rather than computed)? This seems closest to "richer record wins" semantics that already arbitrate WHOOP vs imported nights.

Waiting for this PR #325 been approved to move forward on :

  • Persistence = a follow-up PR stacked on feat(oura): reconstruct BLE sleep-stage hypnogram #325: evidence = the DB rows, a Sleep-tab screenshot, the multi-night stage cross-check, and the idempotency + merge-precedence results.
  • Activity corpus = a third PR: evidence = the JSONL sample, the dedup verification, and the multi-day WHOOP-workout alignment.

pipiche38 added a commit to pipiche38/noop that referenced this pull request Jul 14, 2026
…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
@pipiche38 pipiche38 changed the title Oura hypnogram reconstruction feat(oura): reconstruct BLE sleep-stage hypnogram Jul 14, 2026
@pipiche38

Copy link
Copy Markdown
Author

@ryanbr let me know if this PR can be merged, or not. All my work is based on that.
So if this one is not suitable for merge, I would prefer to fix it

@pipiche38

Copy link
Copy Markdown
Author

Heads-up — the 0x60/0x80 IBI decoders on this branch are now known-wrong; a dedicated fix is up #511.

While building HR-history instrumentation on a real Gen 3 overnight capture, the banked IBIs decoded to an 82% beat-to-beat >200 ms jump rate — not a heartbeat train. Re-decoding the same bytes against open_oura's arse_api_ibi_and_amplitude_event / parse_api_green_ibi_quality_event pinned it to our bit layout:

  • 0x60 was read as a linear MSB-first bitstream; the ring scatters each 11-bit IBI across bytes. (within-packet jitter 773 → 57 ms once corrected)
  • 0x80 was read as a little-endian u16; it's high-byte-first with a quality==1 gate. (583 → 45 ms)

Fix is isolated in #511 (Swift + Kotlin twins, real-capture golden vectors, docs §6.1/§6.4, no public-API change).

Impact on this PR: none functionally — the hypnogram reconstruction rides on the 0x4E/0x5A sleep-phase tags (and #447 on 0x50 MET), neither of which touches 0x60/0x80. But this branch still carries the old decoder + its golden-test ectors, so when the fix lands on main this will need a rebase that picks up the corrected decodeIBIAmplitude/decodeGreenIBIQuality and the updated DecoderGoldenTests vectors (a likely small conflict there). Suggested order: land the decode fix first, then rebase this.

@pipiche38

Copy link
Copy Markdown
Author

@ryanbr I just merge this branch with the recent update on origin/main
Thanks for approving this PR

@pipiche38

Copy link
Copy Markdown
Author

#325 is included in #447 and ready for approval ( #447 (comment))

@pipiche38 pipiche38 closed this Jul 17, 2026
@pipiche38
pipiche38 deleted the oura-hypnogram-reconstruction branch July 22, 2026 18:03
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.

3 participants