Skip to content

docs(whoop5): record the SpO2 protocol facts and the 5.0-vs-MG identity signals#824

Merged
ryanbr merged 4 commits into
mainfrom
docs/whoop5-spo2-path-and-identity
Jul 26, 2026
Merged

docs(whoop5): record the SpO2 protocol facts and the 5.0-vs-MG identity signals#824
ryanbr merged 4 commits into
mainfrom
docs/whoop5-spo2-path-and-identity

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Re-implements the landable parts of #807 (@dnesdan) against current main. Their PR picked up a conflict when #814 rewrote the same @82 paragraph; writing fresh avoids it. Credited as co-author.

Kept, and theirs — the SpO₂ protocol facts. These answer a recurring question with a protocol answer, and weren't recorded anywhere:

  • No GET_SPO2-style opcode exists. Verified against our own CommandNumber table — 80 commands, none oxygen-related. It isn't a pollable gauge, so hunting a missing opcode is a dead end.
  • Computed on-device, during sleep — overnight windows only, never a 24/7 series.
  • The CSV blood_oxygen_pct is a per-cycle aggregate, so it won't equal a mean of raw wire samples.
  • A night with no export value is a real gap, not a NOOP bug.

Dropped — their model-id range table (0…37 = MG, 48…85 = 5.0).

Not for provenance: #822 settled today that decompile-sourced facts are admissible with attribution, so my original objection no longer applies. Dropped because we already do identity better and don't use model-ids anywhere. Whoop5Variant reads the DIS serial prefix (5AM/5AG) and the WG50 hardware-revision token — shipped in #781, sourced from #520. Documented here from our own code instead, including that contradictory signals resolve to .unknown and that .unknown is not MG.

Changed — the v18 field map is a pointer, not a copy.

Their §8.1 duplicated the authoritative table in BLE_REVERSE_ENGINEERING.md. Two copies drift, and it had already started: theirs predates the dynamic_acceleration@41 row added earlier today. One table, one place to keep correct.

Verification: both factual claims checked against the tree rather than taken from the source PR — the command table has no SpO₂ opcode, and Whoop5Variant carries the three identity signals as described. Links resolve, i18n clean, docs only.

#807 stays open — its author may prefer to land their own version, and that's their call to make rather than mine.

ryanbr and others added 4 commits July 25, 2026 22:56
…ty signals

Re-implements the landable parts of #807 (dnesdan) against current main, avoiding a
conflict their PR picked up when #814 rewrote the same @82 paragraph.

Kept, and theirs: the SpO2 protocol facts. No GET_SPO2-style opcode exists —
verified against our own CommandNumber table, 80 commands and none oxygen-related —
SpO2 is computed on-device during sleep, the CSV export is a per-cycle aggregate
rather than a mean of wire samples, and a night with no export value is a real gap.
None of that was recorded anywhere; it answers a recurring question with a protocol
answer instead of a shrug.

Dropped: their model-id range table (0…37 = MG, 48…85 = 5.0). Not because of its
provenance — #822 settled that decompile-sourced facts are admissible with
attribution — but because we already resolve identity a better way and do not use
model-ids anywhere. Whoop5Variant reads the DIS serial prefix (5AM/5AG) and the
WG50 hardware-revision token, shipped in #781 and sourced from #520, and it is
documented here from our own code instead.

Changed: their §8.1 v18 field map is a POINTER, not a copy. BLE_REVERSE_ENGINEERING
already carries the authoritative table, and a second copy drifts — theirs was
already missing the dynamic_acceleration@41 row added earlier today. One table, one
place to keep correct.

Co-authored-by: Dan Urbanek <abc@dnesdan.cz>
Re-review caught two structural mistakes in the previous commit.

Wrong heading level: I used `## 7.9` / `## 7.10` where section 7's subsections are
all `### 7.1`..`### 7.5`, so mine rendered as top-level siblings of section 7 rather
than parts of it.

Wrong parent regardless: section 7 is "Historical-data offload (backfill)". Device
identity and SpO2 semantics are not offload topics, so nesting them there was wrong
even at the right level, and 7.5 -> 7.9 left a phantom gap implying missing content.

Moved to their own top-level sections after §8 Decoded output, numbered 9 and 10,
with File map renumbered 9 -> 11. Checked first that nothing cross-references
PROTOCOL.md's §8/§9 by number or anchor (the §8/§9 hits elsewhere are an Oura design
spec citing its own sections) and that the file carries no table of contents to keep
in sync.

Also re-verified the resolution logic the identity table describes against
Whoop5Variant.resolve: a hardware string saying 5.0 while the serial says MG really
does return .unknown rather than picking a winner.
… for the export shape

Two claims re-checked against the tree rather than left as inherited assertions.

"There is no GET_SPO2-style opcode" was an overclaim. What we can actually say is
that our CommandNumber catalogue has 80 commands and none reads oxygen, and that
independent RE reports none. That is not proof about the strap's command space:
§6 is explicitly a "safe subset", and the 98-vs-87 battery dispute (#818, #592)
shows the map is incomplete. Restated as "nobody has found one", which still
supports the useful conclusion — hunting a missing opcode is the wrong lead.

The per-cycle export shape came from #807 and I had passed it through unverified.
Now confirmed from our own code and cited: bloodOxygenPct is read on the
physiological-cycles row beside recovery_score_pct and day_strain, keyed on
cycleStart/cycleEnd (WhoopExportImporter.swift:272). So "one value per recovery
cycle" is ours to assert, not a claim borrowed on trust.
Final pass over the two sections, checking each assertion rather than the ones I
happened to doubt.

Verified and now cited: the DIS characteristics are ours — BLEManager.swift:473-474
declares disSerialChar 2A25 "Serial Number String" and disHwRevChar 2A27 "Hardware
Revision String". And the sleep-gating claim has in-tree corroboration: our own
decode observes aux_byte_82 nonzero only while the band sleep flag reads asleep, so
the doc cites that instead of asserting it bare.

Marked, because it cannot be verified here: "naps and incomplete nights carry no
SpO2 in the export" is a contributor observation from #807 about WHOOP's export
behaviour, not something this repo can check from the wire. Kept because "my SpO2 is
missing" otherwise reads as a decode failure, but labelled so nobody later cites it
back as established.

Every claim in these sections is now either checked against this tree or explicitly
attributed as unverified.
@ryanbr
ryanbr merged commit 5fb7937 into main Jul 26, 2026
1 check passed
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