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

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
@ryanbr
ryanbr deleted the docs/whoop5-spo2-path-and-identity branch August 2, 2026 07:41
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ty signals (ryanbr#824)

Re-implements the landable parts of ryanbr#807 (@dnesdan) against current main. Their PR
picked up a conflict when ryanbr#814 rewrote the same @82 paragraph; writing fresh avoided
moving a bigger conflict onto them.

KEPT, AND THEIRS — the SpO2 protocol facts, recorded nowhere else:

  - No SpO2 read opcode is known. Our CommandNumber catalogue carries 80 commands
    and none reads oxygen.
  - Computed on-device during sleep — our own decode corroborates it, aux_byte_82
    is nonzero only while the band sleep flag reads asleep.
  - The CSV blood_oxygen_pct is a per-cycle aggregate, read on the
    physiological-cycles row beside recovery_score_pct and day_strain
    (WhoopExportImporter.swift:272), so it will not equal a mean of wire samples.
  - A night with no export value is a real gap, not a decode failure.

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

Not for provenance: ryanbr#822 settled today that decompile-sourced facts are admissible
with attribution, so the original objection no longer applies. Dropped because we
already resolve identity a better way and use model-ids nowhere. Whoop5Variant reads
the DIS serial prefix (5AM/5AG) and the WG50 hardware-revision token — shipped in
ryanbr#781, sourced from ryanbr#520 — and that is documented here from our own code, including
that contradictory signals return .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, and drift had already started:
theirs predates the dynamic_acceleration@41 row added earlier today. One table, one
place to keep correct.

FOUR REVIEW PASSES, ALL FINDING MY OWN MISTAKES

  - Wrong heading level and wrong parent: ## 7.9/7.10 under a section whose
    subsections are ### 7.1..7.5, and neither topic belongs under "Historical-data
    offload". Moved to top-level 9 and 10, File map renumbered to 11 after checking
    nothing cross-references §8/§9 and that the file has no TOC.
  - Overclaimed negative: "there is no GET_SPO2 opcode" restated as "none is known".
    §6 is explicitly a safe SUBSET and the 98-vs-87 dispute shows the map is
    incomplete; the catalogue is evidence, not proof about the strap.
  - Two inherited claims passed through unverified, now sourced: the per-cycle
    export shape (cited to our importer) and the DIS characteristics (cited to
    BLEManager.swift:473-474).
  - One claim that cannot be checked here — naps/incomplete nights carrying no
    export SpO2 — kept but explicitly marked a contributor observation, so it is not
    later cited back as established.

Every claim in these sections is now either checked against this tree or labelled
unverified. Docs only.

Co-authored-by: Dan Urbanek <abc@dnesdan.cz>
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