Repository navigation
WHOOP 5/MG deep-data roadmap: capability labels, validated decode and guarded probes #761
Copy link
Copy link
Open
Description
Context
WHOOP 5/MG support has several active research and implementation threads. This issue is a roadmap/spec issue to keep the user-facing deep-data work coherent: decode what is validated, label what is unavailable, and keep experimental probes guarded.
Related / does not replace:
- Sharing: whoop-local — a single-file, stdlib-only local decoder for WHOOP 5.0/MG (HR, strain, HRV, sleep, IMU, skin-temp °C, events…) #715
- [ble] WHOOP 5/MG raw accel/IMU: community-verified live 0x2B raw-accel stream (cmd 81) that the 5/MG allowlist never arms — gated probe + decode proposal #423
- SpO₂ and Skin Temp show nothing with no explanation — build the capability label Spo2ReTrace already calls for #548
- Data request: WHOOP 5/MG high-rate optical (deep-buffer) capture with a reference pulse-oximeter #532
- [ble] CoreBluetooth state restoration leaves the link half-initialised: connectedPeripheralUUID never set, stale notify never re-armed (iOS, WHOOP 5.0) #613
- [connection] Connection & Sync - Whoop 5 #295
- WHOOP 5.0 raw HCI capture — where to submit? (docs reference #174, which doesn't exist) #103
- Found a repository that might help with Whoop 5 sensor decoding #520
- v18 @82: decode the SpO2 candidate as instrumentation (#103) #709
- Persist the WHOOP 5/MG raw-IMU offload buffer (#423) #675
- feat(android): Kotlin twin of the WHOOP 5/MG R22 deep-buffer + 6-axis IMU decode (#423) #481
Proposed scope
Create a single WHOOP 5/MG deep-data roadmap that ties protocol capability, storage, analytics, and UI behavior together:
- explicit capability labels for HR, HRV, respiration, SpO2, skin temperature, raw optical/deep buffers, IMU, battery/body status, and sync mode;
- validated decoders persist raw/instrumentation data without pretending it is final physiology;
- user-facing cards explain when a metric is unavailable, unsupported by current firmware, or still experimental;
- probe paths stay default-off / diagnostic-only until validated on hardware;
- active strap id and canonical device-family resolution are used everywhere;
- no destructive/write commands to hardware.
Non-goals / safety constraints
- No firmware, decompiled app code, proprietary assets, DRM circumvention, or unsafe BLE writes.
- No invented SpO2/temperature/respiration values from weak signals.
- No default analytics changes from unvalidated PPG/IMU estimates.
- No medical interpretation of raw sensor streams.
Acceptance criteria
- There is a capability model shared across Apple and Android that can explain “available / unsupported / experimental / needs data”.
- Deep-data UI does not silently show blank SpO2/skin-temp/respiration tracks without explanation.
- New protocol facts live in protocol packages/decoders, not scattered hardcoded hex in app UI.
- Experimental probes are behind explicit toggles or diagnostic flows.
- Any persisted raw/deep data has a schema and migration strategy on both platforms.
Verification
- Protocol parsers have fixture tests and CRC/error-path coverage.
- Swift/Kotlin decoder outputs match for shared fixture data.
- BLE changes are compile-tested and validated on real hardware before being called complete.
- App-target Swift changes are built locally because default CI does not compile them.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
vishk23 commentedon Jul 26, 2026
A read-only feature-flag probe for WHOOP — the read verbs exist, and one has a hardware-verified wire format
Short version: WHOOP has a read counterpart to
SET_FF_VALUE(0x78) — two of them — and they are already named in NOOP's own opcode table. Neither is implemented. A third party has since run the reliable one against real hardware and published the wire format. This proposes adopting it as a read-only diagnostic, mirroring the Oura probe NOOP already ships.The Oura precedent NOOP already ships
docs/OURA_PROTOCOL.md§7.4 describes a read-only probe that asks the ring to report a feature's own status, so an absent signal can be attributed to the gate from the ring's own mouth rather than guessed.spo2_status(2f 02 20 04) andrealsteps_status(2f 02 20 0b) ship today (Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift:131,138), the reply decodes asfeature, mode, status, state, subscription(Decoders.swift:394), and an all-zeromode/status/stateis the documented server-gated-OFF signature. That turned "SpO2 is missing, we don't know why" into a confirmed fact on live hardware.WHOOP has no equivalent, and #103 is currently paying the price: we are inferring feature state from a byte reading zero.
What is actually in the opcode table
Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json(CommandNumber) contains two complete, symmetric config families:START_DEVICE_CONFIG_KEY_EXCHANGESEND_NEXT_DEVICE_CONFIGSET_DEVICE_CONFIG_VALUEGET_DEVICE_CONFIG_VALUESTART_FF_KEY_EXCHANGESEND_NEXT_FFSET_FF_VALUEGET_FF_VALUENOOP implements exactly two of these eight:
0x77(119) for the Broadcast-HR flag and0x78(120) for the R22 enable sequence (Whoop5Config.swift). The other six — including both read verbs and both enumeration paths — appear nowhere in the tree outside that JSON name table.External evidence: the wire format, run on real hardware
johnmiddleton12/wearable,docs/specs/2026-05-24-whoop-protocol-complete.md§4 — a hands-on RE session against the author's own WHOOP 4.0 (fw 41.16.6.0), cross-checked against the official app's command builders:The important caveat, quoted verbatim:
So
GET_FF_VALUEis not the verb to build on. The enumeration path (117 -> repeated 118) is the reliable one, and it is the one actually exercised: it produced a complete key dump of that strap — 11 feature flags (general_ab_test,sigproc_10_sec_dp,enable_r19_packets,enable_r19_v2…v6_packets,enable_write_r24_packets,enable_write_r25_packets,enable_sigproc_walk_detector) and 1 device-config key (sigproc_wear_detect).Evidence tiers, stated plainly:
Asherlc/dofeklists0x80 | 128 | GET_FF_VALUE— name only, no layout (and its L164 sync line contradicts its own table; don't use it).DX23876/gooseships a cmd-128 probe with a zero-byte payload and notes "cmd128 GET_FF_VALUE times out (3s)" — consistent with the payload shape above being required. Treat goose's format as unvalidated.tanarchytan/whoop-rsanda9eelsh/whoop-local: neither defines any FF read or enumerate command.Unverified on 5/MG. Everything above was run on a 4.0, and that key list is R19-era — clearly not the 5/MG's R22 set. Whether a 5/MG answers 117/118 at all is exactly the open question.
Proposal
A read-only feature-flag enumeration probe, default-off, diagnostic-only:
START_FF_KEY_EXCHANGE(117) with[0x01], read the count.SEND_NEXT_FF(118) with[0x01]until the cursor returns exhausted; log the key names.SET. NeverGET_FF_VALUE(unreliable per above).This writes command frames but sets no value — structurally identical to the Oura probe, which also writes (
2f 02 20 <id>) in order to read, and which ships today. It falls inside this issue's "probe paths stay default-off / diagnostic-only until validated on hardware".Two implementation notes: the 5/MG
send()allowlist currently drops anything without 5/MG framing, so this needs the same allowlist treatment #423 describes; and on clean-room grounds this is a fact reimplemented with attribution, not lifted code.What it would buy us
"hr,hrv,spo2,skinTemp,sleep,strainLoad"for every WHOOP, which is untrue for both models. The strap's own key list is a far better basis for a capability label than a hardcoded string.Happy to prototype it behind a diagnostic toggle if there's appetite. I don't have a 4.0 to reproduce the published result on, so the first useful step is anyone with a 5/MG confirming whether 117 answers at all.
vishk23 commentedon Jul 27, 2026
Result: the strap answered. 16 flags, and they are exactly the 16 we already hardcode.
Ran the probe from #872 against a WHOOP 5 MG (
WS50_r03), firmware talking over the normal command characteristic.START_FF_KEY_EXCHANGE (117)returnedrevision=1, count=16, SUCCESS, and 16 successiveSEND_NEXT_FF (118)calls walked the whole table. No value was written —SET_FF_VALUE (120)andSET_DEVICE_CONFIG_VALUE (119)are not in this code path.The enumerate verb works on 5/MG. That was the open question and the answer is yes.
Three things this settles
1. Our flag list is complete and correct. These are byte-for-byte the 16 names already in the tree — no extras, nothing missing, nothing renamed. Those names were assembled from observed traffic; this is the first time the strap has been asked to recite its own table, and it agrees. Anyone extending the R22 work can now treat the list as closed rather than as a best guess.
2. There is no hidden SpO2 / oxygen / saturation flag. Sixteen slots, all accounted for, none of them oxygen-related. Whatever gates SpO2 on this hardware, it is not a client-writable feature flag — so the "find the right flag and flip it" approach is a dead end. I'd rather post that as a clean negative than have three more people burn a weekend on it. (Cross-posting to #103, where this has been the standing hypothesis.)
3. There is an index gap. The strap announced 16 and delivered 16, but the indices it served were 1–10 and then 13–18 — it skipped 11 and 12 on its own initiative (the strap drives the cursor in
SEND_NEXT_FF, not the client). So firmware carries an 18-slot table with two retired entries. I have no evidence about what those slots held and am not going to speculate; noting it because a strap on different firmware might serve them, and that would be worth seeing.The one I'd point people at:
ir_hw_switchingOf the 16, that is the name most worth understanding. It is already settable with the machinery in the tree today, and R20 optical blocks carry per-measurement LED source selector bytes — so a before/after capture across a toggle of that flag should show which selector value corresponds to the infrared emitter, by observation rather than by guessing.
That matters because wavelength identity, not the enable bit, is the actual blocker on any oxygenation work. I'm running that experiment against my own 29k-record R20 corpus and will post whatever it shows, including if it shows nothing.
Probe source is #872 (approved). Reproducible on any 5/MG: Test Centre → Connection, then the strap's ⋯ menu.
Add a read-only feature-flag enumeration probe (#761) (#872)
vishk23 commentedon Jul 27, 2026
Hardware data point for the
validKey = 0question in #874@Fanboynz — the
isExhausted/isSkippablesplit is right, and the "never report OUR limitation as the STRAP's behaviour" framing is the correct rule. On the second point, where you note thevalidKey = 0semantics cannot be settled without a strap: the run that produced the published dump bears on it, so here is the relevant detail I did not call out the first time.The 16-entry walk on a WHOOP 5 MG (
WS50_r03) served these indices:Every one of the 16 responses carried
validKey = trueand a decodable ASCII name.validKey = 0was never observed at all, and neither wasindex = 0xFF— the walk ended because the announced count of 16 was satisfied.So on this firmware the table has an 18-slot index space with two retired entries, and the strap steps over its own holes internally — it advanced 10 → 13 on its own initiative, without emitting a hole marker for the client to interpret. The strap drives the cursor in
SEND_NEXT_FF, not us.What that does and does not settle:
validKey = 0means, because this firmware never sent it. Your ambiguity stands unresolved, and I would keep the conservative reading.!validKeyterminator would not have truncated this dump — but only by luck, and your point 1 defect (key == nilending the walk) is real and would bite the moment a name lands outside printable ASCII.Worth stating plainly: one strap on one firmware version is not a general result. A
WG50_r52or a different firmware could well servevalidKey = 0, and that is the capture that would actually settle it. If anyone in the thread has a different 5/MG revision, the probe is read-only and the dump takes about ten seconds.vishk23 commentedon Jul 27, 2026
Hardware result for the read half: both
GET_*_VALUEverbs answer on a 5/MGFollow-up to the enumerate result above (#872) and the roadmap's "guarded probes" line. Ran #890 against the same WHOOP 5 MG (
WS50_r03). Full write-up with the values, the negative result and the data requests is in #103: #103 (comment)Summarising just the parts that update this issue's evidence table:
1.
GET_FF_VALUE(128) is usable on 5/MG — the caveat I quoted here does not reproduce.When I proposed the enumerate probe above I quoted an external 4.0 write-up saying 128's "trailing value field is contaminated by a stale shared buffer, so read on/off is unreliable", and concluded
GET_FF_VALUEwas "not the verb to build on". On this strap it answered cleanly for all 16 flags with values consistent from two independent directions (see #103). I'm withdrawing that recommendation for 5/MG. Either the 4.0 behaviour doesn't carry over, or the 33-byte request body matters — I can't distinguish those, and I don't have a 4.0 to check. Anyone with a 4.0: this is worth one run.2.
GET_DEVICE_CONFIG_VALUE(121) also answers, against the Broadcast-HR key (#181), returningwhoop_live_hr_in_adv_ind_pkt = '0'. That reaches a namespace 117/118 never touched.3. Six of the eight config verbs are now hardware-confirmed on 5/MG:
The remaining gap is the highest-value probe left in this area: 115/116 against the device-config namespace. #872 proved the feature-flag table is exactly 16 keys and closed that surface. The device-config namespace has never been enumerated by anything — so we know it exists, we know it's separately readable, and we have no idea how many keys are in it. Same probe shape as #872, same read-only construction, and it would answer #103's "is there a gating key" question from any strap without needing a subscriber or a capture.
4. Updating the general claim in this issue. I opened this proposing that the table names read verbs nobody implements. Four of those names — 117, 118, 121, 128 — are now confirmed served by firmware, while being implemented by no client in this ecosystem and appearing in no capture posted anywhere here. So the useful rule for this roadmap is: absence from every client is not evidence of absence from firmware, and the
CommandNumbertable is worth probing rather than treating as aspirational. Opcode 96ENTER_HIGH_FREQ_SYNCis the next obvious candidate under that rule — untested, and I am not claiming it works, but it's one round-trip.Also of interest to this issue's capability-label thread (the wizard advertising
spo2for every WHOOP): eight guessed oxygen-related key names all returned FAILURE, which establishes a working key-name oracle — real key returns SUCCESS + value, unknown key returns FAILURE — so candidate names are now testable one round-trip each. Details and the ask for name suggestions are in the #103 comment.vishk23 commentedon Jul 27, 2026
The 115/116 gap is closed: the device-config namespace enumerates, 7 keys
Directly answering the line I left in this thread — "the remaining gap is the highest-value probe left
in this area: 115/116 against the device-config namespace". It has now been run, on the same
WHOOP 5 MG (
WS50_r03), build 215. Full result in#103; the parts that update this
issue's evidence table:
1.
START_DEVICE_CONFIG_KEY_EXCHANGE(115)andSEND_NEXT_DEVICE_CONFIG(116)are both served byfirmware.
revision=1, count=7, seven successive 116 calls, clean terminator (index=255,validKey=false). The table from my read-verb comment above is now complete — all eight config verbsare hardware-confirmed on 5/MG:
2. Another instance of this issue's own rule. 115 and 116 had been sent by no client in this
ecosystem and appeared in no capture posted anywhere here, and firmware serves both. That is now four
separate opcodes — 117, 118, 121, 128 — plus these two, found by asking rather than by waiting for a
capture. Absence from every client is not evidence of absence from firmware is holding up well
enough that I would treat it as the default assumption for this table rather than a hypothesis.
3. The layout-reuse inference paid off, and is worth recording as confirmed. #898 decodes 115/116
with the feature-flag walk decoder (117/118), on the strength of the naming symmetry in
whoop_protocol.json's ownCommandNumbertable — its doc comment flags that explicitly as aninference rather than an observation, designed to fail closed into
.truncatedor an implausible count.It did not fail: plausible count, seven decodable ASCII names, well-formed terminator. The two
namespaces share a record layout.
4. On
validKey = 0/ the #874 question — still not reachable. This walk ended on the announcedcount of 7 and then a terminator; every served entry had
validKey = trueand a decodable name. Sameas the 16-flag walk: this firmware never emits
validKey = 0on either namespace, so @ryanbr'sambiguity stays unresolved and the conservative reading stays right. Two namespaces, two firmware
walks, zero sightings.
5. A defect in my own probe, found by this run. One of the seven values is
0.0— threecharacters.
DeviceConfigReadProbe.value(for:)reports a single byte after the 32-byte name field, soits parsed column renders
'0'for that key. Config values are not single-byte in general. Anyonebuilding on 121/128 will inherit that assumption from my code; the raw record is dumped alongside, which
is what made it visible at all.
ENTER_HIGH_FREQ_SYNC(96) is still untested and still the next obvious candidate under this issue'srule.
Don't call an unread key list "the strap named none" (#761) (#913)
Make the ECG gate hypothesis testable: an opt-in enable_raw_data_w_ec…
Make the ECG gate hypothesis testable: an opt-in enable_raw_data_w_ec…
Separate the two enumeration terminators: don't stop a key walk on va…
Add a read-only feature-flag enumeration probe (ryanbr#761) (ryanbr#872)
Don't call an unread key list "the strap named none" (ryanbr#761) (ry…
Make the ECG gate hypothesis testable: an opt-in enable_raw_data_w_ec…
Separate the two enumeration terminators: don't stop a key walk on va…
paradix86 commentedon Oct 6, 2026
Picking up the capability-model item from this roadmap, per the suggestion in #2580. Checking scope before writing code.
Most of it already exists: the NOOP Limitations grid (#1135),
WhoopLiveCapabilitiesfor the registry, andWhoop5Variantfor MG vs 5.0. What's left:partial, but the registry storesskinTempand the Devices card lists it as captured.partial.isFiveOrMGusescontains("5") || contains("mg")instead ofDeviceFamily.forRegistryModel. Switching changes one case: the legacy"WHOOP"label would gainsteps.Plan, one PR covering both platforms:
WhoopCapabilityMatrixinPackages/WhoopStoreplus a Kotlin twin, mapping metric × {4.0, 5.0, MG} to the existing three states;WhoopLiveCapabilitiesderives from it;No BLE changes, no migration. Existing registry rows are left as they are.
Two questions for @ryanbr :
partialmetrics, or onlyfull?.unknown, should the grid show the more conservative of 5.0/MG, or keep the merged column?For context, I have a WHOOP 5.0 with an active membership and an Android phone, so I can also run
correlate_ground_truth.pyfor #103 and post offsets only.vishk23 commentedon Oct 8, 2026
@paradix86 A contributor’s view from checking current upstream during a local merge:
WhoopLiveCapabilities.basedeliberately includes skin temperature even though its meaning is limited: the source documents a nightly deviation after baseline calibration, dependent on firmware layout. So deriving the registry set from full-only matrix cells would change existing behavior by dropping a metric NOOP already processes. I’d keep “can produce a supported metric” separate from the grid’s evidence/limitations grade, with an explicit projection from the matrix into registry metrics. A test should pin the existing 4.0 set and the 5.0/MG steps difference before the UI is switched over.For an unknown variant, a conservative display makes sense, but a legacy
"WHOOP"registry label is not positive evidence of a 5/MG.DeviceFamilyitself documents that the label was used for both generations, and providesconfirmedRegistryFamily(model:brand:)for evidence-sensitive decisions. Replacing the substring check with a fallback family resolver would grant steps to that ambiguous label. I’d add a separate unknown/legacy case to the matrix rather than let transport fallback establish a capability.One ECG wording correction for the matrix: #891 now contains live R17 and stored R16 reports from MG units without membership. The earlier silence is not evidence of a universal entitlement gate; command mapping and framing explained specific failures. My own r03 report in #1100 used
124=1(now known to be Stop), so it should not be used as a valid corrected-sequence negative. A 5.0 without electrodes and an MG research capture should remain distinct, but “MG is gated” is stronger than the current evidence supports.These are implementation suggestions, not a maintainer scope decision; the stored-set policy still needs @ryanbr’s call.
LeonardSEO commentedon Oct 19, 2026
Some 5/MG data points from public captures, plus a library you can cross-check against
I built wearable5.0 (MIT), a protocol-only library for
WHOOP 5.0 / MG: a canonical schema with an evidence level per fact, a Python reference, and Swift and
Kotlin twins with golden-vector parity in CI. It leans heavily on NOOP's protocol docs and is credited as
such; it independently reached what NOOP already settled (R-R in ms per #2046/#2195, byte 82 as a
tri-mode SpO₂ value, PPG heart rate). A few things it adds for this roadmap:
1. Byte 82 runs on a fixed schedule. On the overnight iOS capture (digitalerdude) byte 82 is 0 in
18,152 of 18,152 records outside 15 windows of exactly 30 s, starting exactly every 1,200 s
(xx:05:37 / xx:25:37 / xx:45:37), all in band state SLEEP; the status words at 77/79 change in the same
records. The daytime MG btsnoop has no windows. Taking the median of valid seconds per window (≥ 5 valid
seconds) gives 7 windows of 93–99 % that night; the other 8 held only status codes.
This may bear on the split evidence noted at
spo2_candidate_82: several windows carry only 1–4 validseconds (one of them a lone 78 % among status codes), so a night-level gated mean weights those thin
windows like full ones. A per-window median with a minimum valid-second count could explain why one
device tracks the app and another moves against it. It is a hypothesis until a captured night with the
app's own SpO₂ is available.
FINDINGS §13
2. What the official app writes. In the same capture the app enumerates both key tables and writes
all 16 feature flags with ASCII values:
'1'forenable_r22_v4_packetsandenable_passive_strap_fit_gen5,'2'for the other 14. It runs a history round every ~600 s(SEND_HISTORICAL_DATA → GET_DATA_RANGE → SEND_HISTORICAL_DATA → one ACK per chunk → often ABORT).
FINDINGS §10
3. Command numbers not in NOOP's table yet: 146/147 (SET_CLOCK / GET_CLOCK), 155–158 (certificate
transfer) and 159 (
LOCK_DEVICE, worth adding to the destructive set).4. Event payloads: events carry subsec @16, payload length @18 and payload @20 (consistent on 2,377 of
2,380 real frames); BATTERY_LEVEL's payload is SoC in tenths @1 and mV @5.
5. A donation tool:
re/prepare_donation.pyturnsrejected_history.jsonl, strap logs,.pklg/btsnoop_hci.logand the WHOOP export into a zip with identity frames, serial/MAC patterns, the journaland notes columns removed. One night plus the WHOOP export for that night would let SpO₂ and HRV be
compared with the official values directly.
Happy to turn any of this into focused PRs if useful.