fix(oura): give the bare 0x44 IBI tag its own channel code, not 0x60's - #1084
Conversation
0x44 `ibi_event` and 0x60 `ibi_and_amplitude_event` share a bit-packed layout byte for byte, so they share a decoder — and until now they also shared a channel code, so once a beat was stored nothing could say which tag produced it. That collapses the question the scoring-channel choice actually turns on. A validation night measured `srcChannel = ibiAmplitude` covering 1.25x the wall-clock it spans — over-counted from one channel alone, and the dominant source of the night's implausible SDNN. But "that channel" is two tags, so the capture cannot distinguish one tag repeating beats across its records from two tags reporting the same beats to each other. Those need different fixes: a time-and-value de-dup versus a channel exclusion. No stored night can tell them apart, because the label merged them before the row was written. Separating the codes costs a case and makes the next capture decisive: - `OuraIBIChannel.ibiBare` / `OuraIbiChannel.IBI_BARE` = 4, and the storage twin `RRSourceChannel.ibiBare` / `RrSourceChannel.IBI_BARE` = 4, pinned to the same durable value on both platforms and appended, never renumbered; - `decodeIBIAmplitude` takes the channel to stamp, defaulting to 0x60's, so every existing call site means exactly what it meant before; - the driver's 0x44 arm passes `ibiBare`. **This is a label, not a filter.** No migration (`srcChannel` is an existing nullable column and this is a new VALUE, not a schema change); the scoring read still excludes only 0x6E, so both tags are read exactly as before and no night's coverage, HRV or scoring moves. Pre-split rows keep code 3 and stay honest: on those, 3 means "0x60 or 0x44, unknown which", which is the state this ends. Verification: `swift test` OuraProtocol 168 + WhoopStore 351, incl. new tests that the same bytes routed as each tag decode to identical intervals under distinct channels, that the driver labels 0x44 without dropping a beat, that a 0x44 row is stored with code 4 AND still returned by the scoring read, and the durable-code pins extended to 4 on both platforms. Kotlin twins of each. `./gradlew testFullDebugUnitTest` 3,470 tests with the same 3 pre-existing locale failures as clean main. App targets built locally since no default CI compiles them: `xcodebuild` Strand (macOS) and NOOPiOS both succeed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AiQZ5XBeAMgnyUugPMqXPi
|
The split did its job: the question it was merged to make answerable now has an answer, from an Capture. Gen-3 ring Result:
That zero is a real absence, not an unreached code path. The raw-notification sidecar logs every What that settles.
So the overlap is real and measurable — and it is intermittent, not a constant property of the The follow-up work therefore takes the de-bank / overlap-trim shape, not an exclusion. A channel One caveat worth recording: this is one ring (Gen 3, |
Branch:
fix/oura-label-0x44-ibi-channel(based onmain@7c7b3f0c, commit4d35fc32)Base:
main. No migration —srcChannelis an existing nullable column (v32 / Room 26) andthis adds a new value, not a schema change.
Follows: #1071 (merged as #1076), whose channel labelling this completes.
The gap
0x44ibi_eventand0x60ibi_and_amplitude_eventcarry the same bit-packed layout byte forbyte, so
OuraDriverroutes both throughdecodeIBIAmplitude. Until now they also shared a channelcode, so once a beat was stored nothing could say which tag produced it.
That matters because it collapses the question the scoring-channel choice turns on. A post-#1076
validation night measured
srcChannel = ibiAmplitudecovering 1.25× the wall-clock it spans —over-counted from that channel alone, and the dominant source of the night's implausible ~197 ms
SDNN. But "that channel" is two tags, so the capture cannot distinguish:
Different fixes. No stored night can tell them apart, because the label merged them before the row
was written.
The change
OuraIBIChannel.ibiBare/OuraIbiChannel.IBI_BARE= 4, with the storage twinRRSourceChannel.ibiBare/RrSourceChannel.IBI_BARE= 4. Appended, never renumbered — the rawvalues are the durable cross-platform storage codes that reach
rrInterval.srcChanneland cross the.noopbakboundary.decodeIBIAmplitudegains achannelparameter defaulting to.ibiAmplitude, so every existingcall site means exactly what it meant before.
0x44arm passes.ibiBare.This is a label, not a filter
Nothing about which beats are read changes. The scoring read (
Reads.rrIntervals) still excludes only0x6E, so both tags remain scored exactly as the merged label was — no night's coverage, HRV, resting
HR or scoring moves. There is a test pinning precisely that, because if it ever starts failing the
split has quietly become a behaviour change, and then the capture it exists to make measurable would
be measuring a different night.
Pre-split rows keep code 3 and stay honest: on those, 3 means "0x60 or 0x44, unknown which" —
which is the state this ends going forward. Nothing is backfilled; the tag was never recorded, so it
cannot be recovered.
Tests
Package-level on both platforms (
swift-packages.yml/./gradlew testFullDebugUnitTest):distinct channels — same layout, different origin;
0x44recordibiBareand a0x60recordibiAmplitude, both still emittingevery beat;
0x44row is stored with code 4 and still returned by the scoring read, while0x60keepscode 3;
allCasescounts and every mapped raw value) now covers the new case automatically.Kotlin twins of each.
Verification
swift test: OuraProtocol 168 pass, WhoopStore 351 pass, 0 failures../gradlew testFullDebugUnitTest: 3,470 tests, the same 3 pre-existing locale failures as cleanmain.xcodebuildStrand (macOS) and NOOPiOS both BUILD SUCCEEDED — app targets have no default CI.a build carrying this will show rows in
srcChannel3 and/or 4 and settle which of the twohypotheses above is true. Worth landing alongside fix(oura): hand the store a record's beats at once so beat order stops being 0 #1082 so one night answers both questions.