Skip to content

docs(whoop5): SpO2 sleep path, SLEEPFLAG, skin-temp wire scale#807

Open
dnesdan wants to merge 1 commit into
ryanbr:mainfrom
dnesdan:docs/whoop5-spo2-sleepflag-facts
Open

docs(whoop5): SpO2 sleep path, SLEEPFLAG, skin-temp wire scale#807
dnesdan wants to merge 1 commit into
ryanbr:mainfrom
dnesdan:docs/whoop5-spo2-sleepflag-facts

Conversation

@dnesdan

@dnesdan dnesdan commented Jul 25, 2026

Copy link
Copy Markdown

What this PR does

Docs-only: expands WHOOP 5.0/MG protocol documentation with facts that already guide the decoders (and community RE) but were easy to miss when reading PROTOCOL.md / WHOOP5_DEEP_DATA.md.

docs/PROTOCOL.md

  • GOOSE / MAVERICK identity — model-id ranges that distinguish WHOOP 5.0 vs MG on the shared fd4b family
  • §8.1 v18 field map summary — verified offsets for HR, R-R, gravity, steps, skin temp, band sleep flag, SpO₂ candidate
  • Band sleep flag (sleep_state @81) — wake / still / asleep / up; explicitly not Light/SWS/REM
  • Skin-temp wire scale — °C = raw/100 on v18; AS6221 chip LSB (/128) is not automatically the historical-record scale
  • SpO₂ honest summary — no GET_SPO2; link to deep-data doc

docs/WHOOP5_DEEP_DATA.md

  • On-device sleep-gated SpO₂ pipeline facts (no live SpO₂ command)
  • Export vs wire aggregation caveats
  • @82 promotion checklist for multi-device validation before any shipped spo2Pct
  • How to help: local correlate_ground_truth.py without posting health values
  • Cross-link to capability roadmap

No code, decode, or metric behaviour changes.

Type of change

  • Documentation

How it was tested

  • Documentation review only (no BLE / package code paths touched)
  • Cross-checked against existing decoder comments in Interpreter.swift (skin_temp_raw /100, sleep_state @81, spo2_candidate_82 @82) and the project's derived-biosignal rules

Checklist

  • Swift package tests N/A (docs only)
  • Android unit tests N/A (docs only)
  • No new build warnings introduced
  • No hardcoded hex frame bytes added to app code
  • Follows docs/CONTRIBUTING.md (facts only; no proprietary firmware/code)
  • Did not commit generated output or secrets

Related issues

… scale

Clarify WHOOP 5.0/MG protocol facts that already guide the decoders but were
easy to miss in the docs: no GET_SPO2 command, on-device sleep-gated SpO2,
v18 @82 as instrumentation-only, band sleep_state (wake/still/asleep/up),
skin_temp_raw °C = raw/100 vs AS6221 chip LSB, and GOOSE/MAVERICK model ids.

Helps #103 and #761 by giving owners a validation checklist and an honest
capability story without inventing metrics or changing decode behaviour.

@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.

Field map checks out — all 14 offsets match our decoder. Useful addition: I got @82 wrong in a doc earlier today because this summary did not exist yet.

Three things before merge:

  1. Where do the model-id ranges come from? 0…37 = MG, 48…85 = 5.0 is not corroborated anywhere in our code, and the wording reads app-derived. If it is from your own captures, say so and it stands. If it came from a decompile we cannot take it — clean-room rule, regardless of accuracy.
  2. It conflicts nowWHOOP5_DEEP_DATA.md changed under you today. One hunk, small rebase.
  3. Add a row for dynamic_acceleration@41 — landed today, sits between @36 and @45, so the table has a hole.

Happy to carry it in with the fixes on top and you as co-author, like #808, if the fork rebase is a hassle.

@ryanbr

ryanbr commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Heads-up: the conflict is now partly my doing. #814 rewrote the @82 paragraph in WHOOP5_DEEP_DATA.md — one hunk, same paragraph you're editing. Rebase on current main and take whichever wording reads better; the facts are the same.

Two things from my review still open:

  1. Where do the model-id ranges come from? (0…37 = MG, 48…85 = 5.0). That's the only blocker — everything else here is grounded in our own decoders. Own captures → it stands. Decompile → also fine now, see below, but say so and it gets attributed.
  2. A row for dynamic_acceleration@41 in the v18 table, which landed today and sits between @36 and @45.

Also worth knowing, since it may change what you thought was submittable: #822 wrote down that decompile-sourced facts are admissible here with attribution, as unvalidated candidates. I'd told a contributor the opposite earlier and it wasn't our rule. Implementations and literals still stay out.

The rest of this is good — the "no GET_SPO2 opcode" and export-aggregation facts aren't recorded anywhere else.

ryanbr added a commit that referenced this pull request Jul 26, 2026
… 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.
ryanbr added a commit that referenced this pull request Jul 26, 2026
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 added a commit that referenced this pull request Jul 26, 2026
…ty signals (#824)

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 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: #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
#781, sourced from #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>
@ryanbr

ryanbr commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Updating the ask — this got smaller, and the provenance question I was blocking on is now moot.

#824 landed the PROTOCOL.md half. The SpO₂ protocol facts are in as §10, credited to you as co-author. So that part of this PR is redundant now, and with it the model-id range table: I dropped it there because Whoop5Variant already resolves 5.0-vs-MG from the DIS serial prefix (5AM/5AG) and the WG50 hardware token, and nothing in NOOP uses model-ids. That means you no longer need to answer where those numbers came from — the table isn't needed either way. (For the record, #822 has since written down that decompile-sourced facts are fine here with attribution, so my original objection was wrong anyway.)

What's still worth landing is the WHOOP5_DEEP_DATA.md half, and I checked — none of it is on main:

  • The @82 validation checklist. This is the one I'd most like. tools: SpO2 @82 multi-device validation harness (#103) — with the fixes it needs to run #814 shipped validate_spo2_candidate.py to measure the promotion bar while the bar itself is still one sentence in prose. Your five points make it explicit — and point 3 ("when the export omits SpO₂, the wire candidate should be empty or out-of-band, not invent a number") is a real falsification test nobody has written down.
  • Band sleep flag vs hypnogram quick reference.
  • "What the strap actually does" / "Wire status today" — partly overlapping §10 now, your call whether to keep, trim or cross-link.

Two mechanical things: rebase on current main (#814 rewrote the @82 paragraph, which is where your conflict comes from), and the how-to-help line points at Tools/linux-capture/correlate_ground_truth.py — that moved to lowercase tools/ in #814, since the two case-variant directories broke every import on Linux.

Drop the PROTOCOL.md changes entirely and this becomes a much smaller PR than the one you opened. Happy to take it from here instead if you'd rather not juggle the rebase — but the checklist is your work and I'd rather it landed as such.

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