Skip to content

log(oura): say WHICH SpO2 channel a first-decoded line is reporting - #2408

Open
pipiche38 wants to merge 2 commits into
ryanbr:mainfrom
pipiche38:fix/oura-spo2-log-names-the-channel
Open

pipiche38 wants to merge 2 commits into
ryanbr:mainfrom
pipiche38:fix/oura-spo2-log-names-the-channel

Conversation

@pipiche38

@pipiche38 pipiche38 commented Sep 23, 2026

Copy link
Copy Markdown

The defect

Oura: first SpO2 decoded (last night) - value N (unit) fired on whichever .spo2 event the history
drain happened to serve first. Two decoders feed that one event with quantities three orders of
magnitude apart:

tag quantity unit tag
0x6F / 0x7B a firmware-computed SpO2 percentage "raw"
0x77 a raw DC perfusion magnitude (−1,016 … 11,709,098 in one overnight capture) "dc_raw"

So the same ring, on consecutive reconnects of one night, printed:

[23:50:43] Oura: first SpO2 decoded (last night) - value 93 (raw)
[02:32:27] Oura: first SpO2 decoded (last night) - value -288 (dc_raw)
[02:39:56] Oura: first SpO2 decoded (last night) - value 101144 (dc_raw)
[06:53:30] Oura: first SpO2 decoded (last night) - value 93 (raw)

Nothing changed about the ring between 23:50 and 02:32 — only which packet arrived first. "raw" names
the channel, not the quantity, and reads as "unprocessed" to anyone who has not read
decodeSpO2PerSample, so there was nothing in the line to tell the two apart.

This cost a reporter a false alarm: they read the five-digit value as a percentage and opened a defect
against SpO2 that was never wrong. Their capture carried 5,089 0x6F packets beside 12,646 0x77
the percentage channel was live the whole night, and their 360,847 stored spo2 rows match.

The change

OuraSpO2Channel — new, in OuraProtocol and com.noop.oura — resolves the channel from the unit tag
and owns both the label and the log line, so the two platforms cannot disagree about what they call
these numbers. One latch per channel replaces the single loggedFirstSpo2 flag:

Oura: first SpO2 percentage decoded (last night) - 93 % (channel "raw")
Oura: first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 101144 (channel "dc_raw")

Each line now names one quantity, the unit tag is still printed so a log ties back to the decoder, and a
session that only ever saw perfusion says so instead of implying a percentage arrived. The % is
appended on the percentage channel only — -288 % would be the same bug in a new costume, and there is
a test for exactly that.

Why a type rather than a unit == "raw" check at each call site

Both known tags are matched exactly: "raw" is the percentage channel, "dc_raw" is perfusion, and
anything else resolves to a third unknown channel that names its tag and never takes a %. Treating
"not perfusion" as a percentage would print a case variant or a future tag's magnitude as N %, which is
the defect this PR fixes. OuraStreamMapping keeps its own unit == "raw" allow-list for the same
reason from the other side: it is a persistence gate, so an unrecognised unit is not stored there and is
not called a percentage here. Nothing reaches unknown today, because the ring emits only those two
tags; it is there for durability.

Scope

Log copy only. No behaviour change, no stored value, no schema, no new user-facing strings, no BLE path
touched. 6 files, +364 / −16 (most of it doc comments and the two test files).

Verification

gate result
OuraProtocol swift test 262 / 0 (10 new — resolver, unknown-unit disposition, the exact line text, the no-%-on-perfusion case, and that decodeSpO2PerSample / decodeSpO2DC / decodeSpO2Stable really stamp the tags the resolver keys on)
Kotlin OuraSpO2ChannelOracleTest 3 / 0 — its literals are the verbatim stdout of the shipped Swift firstDecodedLogLine, captured by linking main.swift against the built OuraProtocol module, not written by hand
Android testFullDebugUnitTest 6381 tests; the 8 failures are pre-existing on upstream/main (RecoveryDriversTest ×2, StandardHrSensorFormatTest ×2, StepsDetailIntegrationTest, TodayExplainabilityTest ×3) — reproduced in a clean worktree at e3f6ef09d before this branch existed
macOS Strand BUILD SUCCEEDED + StrandTests TEST SUCCEEDED
iOS NOOPiOS BUILD SUCCEEDED
Tools/doc_comment_lint.py OK, no new detached doc comments
Tools/i18n_audit.py --ci upstream/main OK (no new strings — a strap-log line is not localized)

The oracle guards Kotlin against the Swift of the day; OuraSpO2ChannelTests pins the same strings on
the Swift side, which is what stops Swift drifting silently. Both directions, per the parity contract.

Refs #2372

`Oura: first SpO2 decoded (last night) - value N (unit)` fired on whichever
`.spo2` event the history drain happened to serve first. Two decoders feed that
event with quantities three orders of magnitude apart — 0x6F/0x7B carry a
firmware-computed percentage, 0x77 carries a raw DC perfusion magnitude — so on
one ring, minutes apart, the log read `value 93 (raw)` and then `value 101144
(dc_raw)`. A reporter read the five-digit one as a percentage and opened a
defect against SpO2 that was never wrong; their capture in fact carried 5,089
0x6F packets beside 12,646 0x77, with the percentage channel live all night.

`OuraSpO2Channel` (OuraProtocol + com.noop.oura) resolves the channel from the
unit tag and owns both the label and the log line, so the two platforms cannot
disagree about what they call these numbers. One latch per channel replaces the
single `loggedFirstSpo2` flag, so each line names one quantity and a session
that only ever saw perfusion says so instead of implying a percentage arrived:

  Oura: first SpO2 percentage decoded (last night) - 93 % (channel "raw")
  Oura: first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 101144 (channel "dc_raw")

The `%` is appended on the percentage channel only — `-288 %` would be the same
bug in a new costume, and a test pins that. `OuraStreamMapping` keeps its own
`unit == "raw"` allow-list on both platforms and is untouched: it is a
persistence gate, so an unrecognised future unit must fall on the "do not store"
side, whereas this resolver has to name every sample it is handed. Log copy
only; no behaviour, no stored value, no new strings.

Verified: OuraProtocol 262/0 (10 new, incl. the decoders really stamping those
tags); Kotlin `OuraSpO2ChannelOracleTest` 3/3 asserting the verbatim stdout of
the shipped Swift `firstDecodedLogLine`; Android 6381 tests, the 8 failures are
pre-existing on `upstream/main`; macOS `Strand` build + `StrandTests` TEST
SUCCEEDED; iOS `NOOPiOS` BUILD SUCCEEDED; doc lint and `i18n_audit --ci` clean.

Refs ryanbr#2372

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RQ5haNgsWbrEFMpjviYns8

@ryanbr ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks @pipiche38. The premise checks out against the decoders: OuraSpO2.init defaults unit to "raw", so 0x6F/0x7B carry it implicitly while only 0x77 stamps "dc_raw". Both platforms build the line identically, every latch reset site was converted, and the oracle being captured Swift stdout rather than hand-written twins is the right instinct for this bug class.

One blocker, and it is small.

The parity ledger fails

parity-governance only runs when Tools/ is touched, so CI stays green on this. I ran it: main is clean, so the findings are this branch's.

add-unpaired-function: Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift::forUnit/1#1
add-unpaired-function: android/app/src/main/java/com/noop/oura/OuraEvents.kt::forUnit/1#1

Identical name and arity on both sides, so the pairing needs the annotation firstDecodedLogLine already has and forUnit does not. Adding this to the Swift doc clears it (tested: declared twin references 966 to 967, finding gone):

/// Kotlin twin: `OuraSpO2Channel.forUnit`.

Behind that one sits a second finding, twin-map-authority-drift|unpaired_properties. Cause is that the scanner indexes Swift's channel but not Kotlin's, because the Kotlin one is a top-level extension property with a dotted receiver. That resolution is a maintainer call, so leave it to me rather than chasing it here.

Not blocking

forUnit treats anything that is not dc_raw as a percentage and appends %. Your own oracle encodes where that bites:

3|DC_RAW|percentage|first SpO2 percentage decoded (last night) - 3 % (channel "DC_RAW")

A case variant of the perfusion tag prints a perfusion magnitude with a percent sign, which is the bug this PR exists to stop, in a new costume. The body defends the default by contrast with OuraStreamMapping's allow-list, but naming every sample does not require guessing: a third unknown case that names the tag and omits % satisfies both. Nothing reaches it today, since the ring emits only those two tags, so this is durability rather than a live defect. Your call whether to take it now.

…tag as unknown

Review follow-up. `forUnit` had no `Kotlin twin:` reference, so the parity ledger
counted both sides as one-sided (`add-unpaired-function ... forUnit/1#1`, Swift
and Kotlin). The reference is now on both declarations.

`forUnit` also treated anything that is not `dc_raw` as a percentage, so a case
variant or a future tag would print its magnitude with a `%` on it: the defect
this type exists to stop. Both known tags (`raw`, `dc_raw`) now match exactly
and anything else resolves to a third `unknown` channel, which names its tag and
never appends `%`. Nothing reaches it today (the ring emits only those two tags);
this is durability. The per-channel latch gains the matching third slot for free.

Verified: OuraProtocol 263/0; Kotlin `OuraSpO2ChannelOracleTest` 3/3 against the
re-captured stdout of the shipped Swift (15 rows, 5 of them unknown tags);
`parity_ledger.py` leaves only `twin-map-authority-drift|unpaired_properties`,
which the reviewer kept; doc lint clean.

Refs ryanbr#2372

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV
@pipiche38
pipiche38 requested a review from ryanbr September 23, 2026 12:06
@pipiche38

pipiche38 commented Sep 23, 2026

Copy link
Copy Markdown
Author

Thanks @ryanbr. Both points are in fa1d651d2, on top of the original commit.

Ledger. forUnit now has a Kotlin twin: / Twin of Swift reference on both sides. parity_ledger.py on the branch now reports only twin-map-authority-drift|unpaired_properties (the channel extension property), which I have left to you as you asked.

The unknown case: taken. You're right that it is the same bug in a new costume, and the allow-list argument I made was the wrong way round. forUnit now matches "raw" and "dc_raw" exactly, and anything else becomes a third unknown channel. That channel prints its tag and never gets a %:

first SpO2 sample on an unrecognised channel (NOT known to be a percentage) decoded (last night) - 3 (channel "DC_RAW")

percentageUnit / PERCENTAGE_UNIT names the "raw" tag next to the perfusion one, and the ledger pairs the two constants. The per-channel latch gets a third slot with no extra code. I re-captured the Kotlin oracle from the shipped Swift's stdout. It has 15 rows now, and 5 of them are unknown tags: raw_adc, "", DC_RAW, RAW and dc_raw2. The Swift tests pin the same cases.

Verified: OuraProtocol 263/0; OuraSpO2ChannelOracleTest 3/3 against the re-captured stdout; doc lint clean. No app-target Swift changed: the app only puts the enum in a Set, with no exhaustive switch over it.

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.

2 participants