Skip to content

Oura integration - WIP#143

Draft
pipiche38 wants to merge 18 commits into
ryanbr:mainfrom
pipiche38:oura-integration-clean
Draft

Oura integration - WIP#143
pipiche38 wants to merge 18 commits into
ryanbr:mainfrom
pipiche38:oura-integration-clean

Conversation

@pipiche38

@pipiche38 pipiche38 commented Jul 9, 2026

Copy link
Copy Markdown

What this PR does

  • Timestamp decoding
  • TimeSync
  • Sleep tracking
  • Activity tracking
  • Fixing cursor management and anchor

PS/ Documenting the findings in docs/OURA_PROTOCOL.md

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

Test environment is MacOs. I'm rebuilding the all application and running it. Comparing Oura ring Gen 3 collected data against Whoop/MG (connected to Whoop App)

Checklist

  • Swift package tests pass for any package I touched (swift test in Packages/<name>)
  • Android unit tests pass if I touched android/ (./gradlew testFullDebugUnitTest)
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens — no hardcoded colors, fonts, or spacing
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

pipiche38 and others added 16 commits July 8, 2026 11:39
  The 0x43 debug spill wasn't documented, so I added it in three linked spots:
- §2.4 (reassembly) — a dated "byte-alignment desync → phantom tags" note: how a run of 0x43 debug records surfaced as a phantom 0x4C "sleep_summary" because 0x4C=L/0x49=I/0x53=S are legal ASCII chars that alias real tags, plus the defensive resync rule (reject len<4, resynchronise on implausible type/len, never let a mid-stream byte mint a Tier-B summary).
- §6.15 (0x43) — expanded with the actual decoded strings ("Sw to App", "in_bed=0", "…in_info=6", "bc 0x43"), the TLV layout, and the aliasing caution.
- §6.12 (sleep summaries) — a "beware phantom sightings" warning so nobody chases an ASCII 0x4C as a real fixture.
Gave .debugText its own ingest case (it previously fell into default and was dropped). It now logs each non-empty, trimmed diagnostic string with its ring counter:
  Oura: debug (0x43) rt=60470 "in_bed=0"
  Diagnostics only — never persisted or scored. As a bonus, decoding these here also means a debug string can no longer silently alias a real event tag on that code path.
…wo mechanisms:

1. Drop-prefix list for the known high-rate firmware chatter:
  DHR_mode, DHR data sub, DHR_state, DHR_info,   ← ~150ms HR state-machine spam
  PPG_cont, S:, E:, AFs, blestda, nr, BQ         ← per-beat PPG windows / AGC / BLE state
  1. Case-sensitive on purpose: the uppercase S:/E: PPG markers are dropped, while the lowercase s:/e: sleep boundaries survive.
  2. Consecutive-duplicate suppression — collapses runs of identical lines (e.g. the DHR_info:neg t ×20 burst) via a lastDebugText guard.
The .streaming block now hands the ring the current UTC (OuraCommands.syncTime)
BEFORE draining history, so it emits a usable 0x42 time-sync event (§5.5).
Without this every fetched record stayed "[no anchor yet]" and last-night
sleep / skin-temp could not be placed on a calendar day (Sleep screen read
matched=0). SyncTime writes the ring clock, exactly as the official Oura app
does on every connect. Sent once per session, before the first fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OuraReassembler.feed only validated len >= 4, never the type byte. Once
byte-misaligned inside a 0x43 debug-text payload, ASCII letters that alias real
tags (p=0x70, I=0x49, L=0x4C, Q=0x51) minted a PHANTOM "summary" whose bogus len
swallowed the following real records (e.g. the sleep-phase timeline). Now a
record only starts on a plausible type - a known OuraEventTag or the 0x11/0x0D
outer responses that round-trip - else drop one byte and re-scan. Adds
isPlausibleRecordStart + 3 FramingTests; realigns instead of stalling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reassembler-misaligned phantom landing on a known spo2 tag byte decoded to
impossible values and got persisted (integration build: 159 spo2Sample rows,
-320..12,856,474), violating the honest-data invariant on a ring whose SpO2
feature 0x04 is gated OFF.

OuraStreamMapping now persists an .spo2 sample only when its value is in the
plausible oxygen-saturation band [85,100], aligned with open_oura tools/run_spo2.py
(r-ratio calibration clamped to [85,100]) and Oura's own reporting floor. Values
outside are DROPPED, never clamped - clamping garbage to "100%" would fabricate an
oxygen reading. Only 0x6F yields direct percentages (~95-96, s6.5); 0x7B (unpinned
uint16) and 0x77 dc_raw (PPG waveform) are not percentages, and nothing downstream
reads spo2 red (AnalyticsEngine nulls spo2Pct), so this loses no real signal and
yields zero rows on the gated-off ring. Mirrored in the Kotlin twin for parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…§6.12.1)

   Investigated open_oura docs/algorithms/sleepnet.md. The Oura app's 4-stage
   hypnogram is NOT on the BLE wire: it is SleepNet, an on-PHONE PyTorch model
   (per-30s epochs) shipped AES-256-GCM-encrypted with a server-delivered,
   cloud-cached key — the one Oura metric not reproducible without Oura's cloud.

   Documents the consequence for NOOP's honest-data invariant: we do not chase
   SleepNet. A NOOP sleep session is built from signals that cross the BLE boundary
   — the ring's Tier-A 2-bit 0x4E/0x5A phase codes (OURA_SLEEP_PHASE), its 0x49/0x76
   boundaries and 0x43 debug in_bed/s:/e: narration, plus NOOP's own staging over
   raw HR/HRV/temp/motion — never relabelled as "Oura sleep stages". Adds a
   [sleepnet] citation key.
A framing desync mints a record with a garbage ring timestamp that, at 100 ms/tick,
converts to a date days-to-years from the anchor yet still inside the loose
2020-2035 epoch gate (rt~1.9e9 -> +6yr/2033; rt~16.7M -> +19 days). On live Gen 3
this scattered ~25% of a night's skin-temp rows across 2020-2034, so they never
landed on the correct calendar day - and the same phantoms carried the garbage
SpO2 values.

OuraDriver.unixSeconds now rejects any conversion more than +1 day after or
-90 days before the session anchor (history is always recent-past relative to the
0x42 anchor). Returns nil so the caller drops/parks the sample instead of banking a
mis-dated row (honest-data invariant) - the single chokepoint every history sample
passes. Mirrored in the Kotlin twin; +4 tests (Swift+Kotlin), OURA_PROTOCOL.md 5.5
documents the guard. 83 OuraProtocol tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AnalyticsEngine derives sleep from the gravity-driven SleepStager, and an Oura ring
streams no accelerometer - so Sleep + Skin Temp stayed blank even with a night of
correctly-dated OURA_SLEEP_PHASE events. NOOP now builds its OWN session from the
ring's coarse phase codes (the polished hypnogram is SleepNet, cloud-gated, not on
the wire - OURA_PROTOCOL.md 6.12.1).

- OuraSleepSessionBuilder (pure): (ts, 2-bit stage) -> [SleepSession] with merged
  StageSegments + efficiency; splits on gaps, drops sub-minimum/all-wake runs.
- analyzeDay gains providedSleepSessions: when set it REPLACES the gravity detector,
  so the ring's night flows through the same funnels (sleep totals + skin-temp
  window). nil default keeps every WHOOP caller byte-identical.
- 11 tests incl. an end-to-end analyzeDay-with-no-gravity proof.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deep-dived Th0rgal/open_oura (Rust workspace RE'd from libringeventparser.so + the
Android app) and folded the findings in as section 9 (leads to fixture-validate,
per the doc's trust rule):
- canonical connect->sync sequence + the "no RData 0x03 on a normal pull" rule;
- authoritative opcode / 0x2F sub-op set; cursor = deciseconds; legacy vs extended;
- feature-gating mechanism (server flags default false client-side) explaining the
  SpO2 / real_steps OFF defaults;
- undocumented event tags (0x62 respiration spot, 0x8B spo2_r_pi, 0x71/0x6E IBI,
  0x59/0x56/0x6C/0x74/0x84/0x86, 0x87/0x88 backend-gated, 0x80 gen conflict);
- RData raw sampler (0x03) + the raw-accelerometer lead; batched timestamping;
- reference SpO2 quadratic + skin-temp algorithm.
Flags two actions in section 8: the SyncTime (0x12) send-layout mismatch and the
skinTempFunnel 300-sample floor vs open_oura's ~120.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ded a live body-weight provider from the profile down to the estimator, matching the codebase's existing closure-injection style:

AppModel.profile.weightKg
  → SourceCoordinator(bodyweightKg:)          [new param]
    → OuraLiveSource(bodyweightKg:)           [new param]
      → OuraActivityEstimator.summarize(bodyweightKg: bodyweightKg())

- Read live (a closure, not a captured value), so if you edit your weight in Settings it takes effect on the next flush.
- Removed the activityBodyweightKg = 75.0 constant entirely.
- Default fallback is 70 kg (only used if profile weight is unset/≤0) — deliberately matching the production calorie path's own 70 kg fallback, so there's one consistent default across the app rather than introducing a second (75).
 The old syncTime built a 24-bit floor(unix/256) coarse time (~4.3-min
 resolution) with a bogus 0xF6 trailer copied from the 0x85 RTC-beacon.
 This did not match the native client's req_sync_time(secs, 0) and could
 set the ring clock wrongly / be silently ignored (masked only by the
 ring's own RTC still yielding a ~correct 0x42 anchor).

 Now emit `12 09 <unix_secs: u64 LE (8 B)> <tz: i8 half-hours>`, tz=0
 (UTC) as the reference client sends; NOOP buckets local days downstream.
 Mirrored byte-for-byte in the Kotlin twin. Tests pin the byte layout and
 the signed tz byte on both sides. Doc §5.4/§8/§9.2 updated to FIXED.
 An Oura ring streams no accelerometer, so the gravity-driven SleepStager
 returns nothing for its nights: the Sleep card read blank AND the
 skin-temp funnel window (gated to a sleepSession) never opened.

 IntelligenceEngine now extracts the OURA_SLEEP_PHASE events from the
 night window's events (already read for the off-wrist backstop, no extra
 store read), builds sessions via OuraSleepSessionBuilder, and passes them
 to analyzeDay as providedSleepSessions. When non-nil these replace the
 gravity detector's output, so the ring's night flows through the SAME
 funnels the WHOOP path uses (sleep totals, skin-temp window, rest). Any
 non-Oura owner emits no such events, so the gravity stager stays
 byte-identical for every existing device. The built sessions land in
 res.cachedSleep, which the existing pipeline already persists.

 Kotlin twin: added providedSleepSessions to AnalyticsEngine.analyzeDay,
 the new OuraSleepSessionBuilder, and the IntelligenceEngine wiring
 (parsing `phase` from EventRow.payloadJSON). StrandAnalytics 964 tests
 pass, incl. the builder suite.
…lock

The phantom guard (unixSeconds anchor-relative window) already rejected a
misframed record's garbage ring timestamp on the ingest path. But the
pending-anchor DRAIN collapsed the two reasons unixSeconds returns nil --
"no anchor yet" and "anchor present but rt is phantom" -- into a single
'?? now` fallback. So a misframed skin-temp/SpO2/phase parked before the
anchor landed was stamped at wall-clock and BANKED, slipping past the
guard and polluting "today" with a bogus sample (honest-data violation).

Expose driver.hasAnchor so the drain can tell them apart: anchor present
+ rt rejected -> DROP (and log the count); no anchor this session ->
keep the honest wall-clock fallback so a real last-night record is
placed at ~arrival rather than silently lost.
…p source)

The decoded OURA_SLEEP_PHASE (0x4E) events turn out to be sparse bursts
the ring emits only at connection time, not a continuous overnight
timeline -- a real 8 h night persisted ~30 phase events clustered at the
sync moments, none during sleep, so a session built from them
under-counts (9.2 h read as ~5 h).

The ring's firmware instead narrates its OWN sleep decision as 0x43
debug text: `check_sleep`, then `s: <rt>` (bedtime) / `e: <rt>` (wake),
where <rt> is a ring timestamp in the 0x42 anchor domain -- so
unixSeconds(rt) converts each straight to UTC. OuraCheckSleepParser
(pure, both platforms) extracts the window and OuraLiveSource logs the
anchored bedtime->wake span. Validated on device: last night read
23:46 -> 07:43 (7h 56m) vs the wearer's actual 23:49 -> 07:41.

A lone `e:` can arrive with no fresh `s:`, pairing the new wake against
the previous night's stale start -- observed live as a phantom 33 h
window. An 18 h max-window guard rejects it (honest-data).

Also confirmed and documented (OURA_PROTOCOL.md 6.15): the Tier-B
sleep_summary tags (0x49/4B/4C/57/58 = ASCII I/K/L/W/X) captured so far
are framing-desync aliases of debug text ("DHR_state:2", "batt: 100"),
not real summaries -- do not decode until a clean binary fixture.

INVESTIGATION prototype: the window is LOGGED, not yet persisted as a
sleepSession.
@pipiche38 pipiche38 marked this pull request as draft July 9, 2026 08:11
pipiche38 added 2 commits July 9, 2026 10:28
   Promote the §6.15 check_sleep prototype to a real sleepSession. The
   ring's own bedtime->wake decision (validated on device against imported
   WHOOP reference: 476 min / 23:46->07:43 vs WHOOP 461-472 min /
   23:49->07:41 -- within ~4 min) is the reliable duration source where the
   sparse OURA_SLEEP_PHASE bursts under-count.

   - OuraLiveSource persists an OURA_SLEEP_WINDOW event (ts=bedtime,
     payload {start,end}) on a stable window; DO NOTHING upsert => one row
     per night.
   - OuraSleepSessionBuilder.session(fromWindowStart:end:) builds one
     session: a single stage-UNKNOWN "asleep" segment, efficiency 1.0.
   - hypnogramMetrics counts TST as any non-wake time -- byte-identical for
     every gravity/phase session (light|deep|rem are the only non-wake
     labels those stagers emit), and it lets the "asleep" window contribute
     its full duration to total sleep WITHOUT inventing deep/REM/light
     (those stay 0). Honest: real duration, blank stages.
   - IntelligenceEngine reads OURA_SLEEP_WINDOW and PREFERS it over the
     phase-event builder (gravity stager for non-Oura owners).

   Kotlin twin throughout. StrandAnalytics 973 / WhoopStore 234 tests pass.
   NOTE: surfacing is still gated -- the ring streams no overnight HR so
   IntelligenceEngine's hr>=200 night gate skips Oura nights; addressed
   next.
The persisted OURA_SLEEP_WINDOW never reached a card: an Oura ring banks
no overnight HR (disconnected while you sleep), and two HR-only gates
blocked it. Fix both — but rank a bare window BELOW any full record, so
the ring surfaces only nights nothing richer owns.

1. Day ownership. resolveDayOwner marked hasData from HR presence alone,
   so the active ring (priority 0) lost every night to an imported WHOOP
   strap (priority 2). It now also counts a persisted OURA_SLEEP_WINDOW —
   BUT as new DayOwnerResolver.Candidate.richData=false. A richer record
   (richData=true: HR-derived stages/recovery/HRV) wins before device
   priority, so a WHOOP import keeps a day it fully recorded (same
   duration, but stages+recovery preserved) and the ring's bare window
   only surfaces a day nothing richer owns. richData defaults true, so
   every legacy candidate collapses to the old priority-only order —
   existing days are byte-identical.
2. Scoring gate. The loop skipped any night with < 200 HR samples. It now
   also scores a night with an OURA_SLEEP_WINDOW (duration honest from the
   window; HR-derived fields stay null) — reached only on days the ring
   actually owns per (1).

New WhoopStore.hasEvent(deviceId:kind:from:to:) — a LIMIT-1 EXISTS probe
(no payload decode) drives both. Kotlin twin throughout (DayOwnerResolver
richData, WhoopDao.hasEvent + repo, IntelligenceEngine). WhoopStore 235 /
StrandAnalytics 976 tests pass (+3 resolver richness cases).

Validation: last connection logged the ring's Jul 7->8 check_sleep window
at 9h12m = 552 min, matching the imported WHOOP night's totalSleepMin=552
to the minute. That night WHOOP (richer) keeps the day; Jul 8->9 (7h56m,
no WHOOP import) the ring owns and surfaces.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant