Skip to content

WHOOP 5/MG deep-data roadmap: capability labels, validated decode and guarded probes #761

Open

Description

@tigercraft4

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:

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.

Activity

  1. vishk23 commented on Jul 26, 2026

    @vishk23

    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) and realsteps_status (2f 02 20 0b) ship today (Packages/OuraProtocol/Sources/OuraProtocol/Commands.swift:131,138), the reply decodes as feature, mode, status, state, subscription (Decoders.swift:394), and an all-zero mode/status/state is 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:

    key exchange bulk enumerate set get
    Device config 115 START_DEVICE_CONFIG_KEY_EXCHANGE 116 SEND_NEXT_DEVICE_CONFIG 119 SET_DEVICE_CONFIG_VALUE 121 GET_DEVICE_CONFIG_VALUE
    Feature flags 117 START_FF_KEY_EXCHANGE 118 SEND_NEXT_FF 120 SET_FF_VALUE 128 GET_FF_VALUE

    NOOP implements exactly two of these eight: 0x77 (119) for the Broadcast-HR flag and 0x78 (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:

    START_*(115/117)     payload [0x01]                   -> 0a 01 01 <u16 count LE>
    SEND_NEXT_*(116/118) payload [0x01]  (a CURSOR, not   -> 0a 01 01 <flags> 01 <key name, NUL-padded>
                         an index; repeat to walk)           0a 01 01 ff ...   (exhausted)
    SET_*_VALUE(119/120) [0x01][32-byte key][32-byte value]
    GET_*_VALUE(121/128) [0x01][32-byte key]              -> 0a 00 ...        (see caveat)
    

    "It is NOT crypto — plaintext UTF-8 key/value strings."

    The important caveat, quoted verbatim:

    "GET_*_VALUE(121/128): no app builder; [0x01][32-byte key] returns the key + value but the trailing value field is contaminated by a stale shared buffer, so read on/off is unreliable — the app never reads values, only enumerates + sets."

    So GET_FF_VALUE is 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/dofek lists 0x80 | 128 | GET_FF_VALUE — name only, no layout (and its L164 sync line contradicts its own table; don't use it).
    • DX23876/goose ships 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-rs and a9eelsh/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:

    1. START_FF_KEY_EXCHANGE (117) with [0x01], read the count.
    2. Repeat SEND_NEXT_FF (118) with [0x01] until the cursor returns exhausted; log the key names.
    3. Log once per connect. Never SET. Never GET_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

    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.

  2. vishk23 commented on Jul 27, 2026

    @vishk23

    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) returned revision=1, count=16, SUCCESS, and 16 successive SEND_NEXT_FF (118) calls walked the whole table. No value was written — SET_FF_VALUE (120) and SET_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.

     1. enable_r22_packets            9.  disable_pip_r26_packets
     2. enable_r22_v2_packets        10.  wear_detect_bias
     3. enable_r22_v3_packets        11.  hr_ch_switching
     4. enable_r22_v4_packets        12.  ir_hw_switching
     5. enable_r22_v5_packets        13.  enable_passive_strap_fit_gen5
     6. enable_r22_v6_packets        14.  enable_sig11_during_sleep
     7. enable_r22_v8_packets        15.  dorset_inhibit_wpt
     8. make_hrfm_visible            16.  enable_sig12
    

    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_switching

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

  3. vishk23 commented on Jul 27, 2026

    @vishk23

    Hardware data point for the validKey = 0 question in #874

    @Fanboynz — the isExhausted / isSkippable split 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 the validKey = 0 semantics 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:

    1 2 3 4 5 6 7 8 9 10   13 14 15 16 17 18
                        ^^^^^ 11 and 12 never served
    

    Every one of the 16 responses carried validKey = true and a decodable ASCII name. validKey = 0 was never observed at all, and neither was index = 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:

    • It does not determine what validKey = 0 means, because this firmware never sent it. Your ambiguity stands unresolved, and I would keep the conservative reading.
    • It does show the ambiguity is not reachable on this path for this firmware — holes are handled strap-side and are invisible to the client. A walk that trusts the announced count and steps by the strap's own index gets the full list regardless of which reading is correct.
    • It also means the old !validKey terminator would not have truncated this dump — but only by luck, and your point 1 defect (key == nil ending 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_r52 or a different firmware could well serve validKey = 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.

  4. vishk23 commented on Jul 27, 2026

    @vishk23

    Hardware result for the read half: both GET_*_VALUE verbs answer on a 5/MG

    Follow-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_VALUE was "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), returning whoop_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:

    key exchange bulk enumerate set get
    device config 115 untested 116 untested 119 shipped (#181) 121 ANSWERED
    feature flags 117 answered (#872) 118 answered (#872) 120 shipped 128 ANSWERED

    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 CommandNumber table is worth probing rather than treating as aspirational. Opcode 96 ENTER_HIGH_FREQ_SYNC is 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 spo2 for 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.

  5. vishk23 commented on Jul 27, 2026

    @vishk23

    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) and SEND_NEXT_DEVICE_CONFIG(116) are both served by
    firmware.
    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 verbs
    are hardware-confirmed on 5/MG
    :

    key exchange bulk enumerate set get
    device config 115 ANSWERED 116 ANSWERED 119 shipped (#181) 121 answered (#890)
    feature flags 117 answered (#872) 118 answered (#872) 120 shipped 128 answered (#890)

    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 own CommandNumber table — its doc comment flags that explicitly as an
    inference rather than an observation, designed to fail closed into .truncated or 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 announced
    count of 7 and then a terminator; every served entry had validKey = true and a decodable name. Same
    as the 16-flag walk: this firmware never emits validKey = 0 on either namespace, so @ryanbr's
    ambiguity 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 — three
    characters. DeviceConfigReadProbe.value(for:) reports a single byte after the 32-byte name field, so
    its parsed column renders '0' for that key. Config values are not single-byte in general. Anyone
    building 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's
    rule.

  6. paradix86 commented on Oct 6, 2026

    @paradix86

    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), WhoopLiveCapabilities for the registry, and Whoop5Variant for MG vs 5.0. What's left:

    1. Grid and registry are separate hardcoded sources and already disagree. For a 4.0 the grid shows skin temp as partial, but the registry stores skinTemp and the Devices card lists it as captured.
    2. 5.0 and MG share one column. A 5.0 has no ECG electrodes; an MG is gated (WHOOP 5 MG: all three TOGGLE_LABRADOR (ECG) commands return SUCCESS and produce no data — six hypotheses, and which ones you can help rule out #891). Both show partial.
    3. isFiveOrMG uses contains("5") || contains("mg") instead of DeviceFamily.forRegistryModel. Switching changes one case: the legacy "WHOOP" label would gain steps.

    Plan, one PR covering both platforms:

    • a pure WhoopCapabilityMatrix in Packages/WhoopStore plus a Kotlin twin, mapping metric × {4.0, 5.0, MG} to the existing three states;
    • the grid renders from it with separate 5.0 and MG columns, and WhoopLiveCapabilities derives from it;
    • tests on both sides, with a Swift-generated expected literal in the Kotlin test.

    No BLE changes, no migration. Existing registry rows are left as they are.

    Two questions for @ryanbr :

    • Should the stored registry set include partial metrics, or only full?
    • When the variant is .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.py for #103 and post offsets only.

  7. vishk23 commented on Oct 8, 2026

    @vishk23

    @paradix86 A contributor’s view from checking current upstream during a local merge:

    WhoopLiveCapabilities.base deliberately 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. DeviceFamily itself documents that the label was used for both generations, and provides confirmedRegistryFamily(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.

  8. LeonardSEO commented on Oct 19, 2026

    @LeonardSEO

    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 valid
    seconds (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' for enable_r22_v4_packets and
    enable_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.py turns rejected_history.jsonl, strap logs, .pklg /
    btsnoop_hci.log and the WHOOP export into a zip with identity frames, serial/MAC patterns, the journal
    and 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions