Repository navigation
[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
Copy link
Copy link
Open
Description
TL;DR
- On WHOOP 4.0, the type-43
REALTIME_RAW_DATAstream carries genuine ~100 Hz 6-axis raw IMU, already fully decoded in-tree (whoop_protocol.jsonvariant1917, "Gen4-VERIFIED" with calibrated scales) — but the app mean-reduces each axis and persists nothing ("persistence still ignores raw43",FrameRouter.swift). - On WHOOP 5/MG, community evidence (Sophonbot0/whoop-vault, real 5.0 hardware on the fd4b service) indicates a live raw-accelerometer stream exists:
START_RAW_DATA(81) → type-0x2B frames of packed int16-LE XYZ triples at ±8 g scale — the same 1/4096 LSB/g pinned on Gen4. whoop-vault banks it into able_acceltable ("raw accelerometer (XYZ, g)"). - NOOP cannot currently see any of this on a 5/MG: the
send()allowlist drops 63/81/82/106 ("no WHOOP 5/MG framing for this command yet",BLEManager.swift), and type-43 payloads are discarded on receipt. - The full 6-axis IMU stream (packet 51, via
TOGGLE_IMU_MODE/106) is confirmed refused by 5.0 firmware: command acks SUCCESS, stream never materializes (whoop-vault). No known live-gyro path on 5/MG. - Two doors appear unprobed by anyone:
TOGGLE_IMU_MODE_HISTORICAL(105) — if it banks IMU into the historical buffer, that suits the offload architecture far better than a live flood — andGET_RESEARCH_PACKET(132). Neither opcode is in NOOP's command enum.
Opening this to coordinate before touching the BLE path, per CONTRIBUTING.
Evidence
In-tree, WHOOP 4.0 (raw IMU is real and already decoded):
Packages/WhoopProtocol/.../whoop_protocol.json,REALTIME_RAW_DATAvariant1917:accelX/Y/Z@ offsets 89/289/489,gyroX/Y/Z@ 692/892/1092, 100 samples/axis, int16 LE. Schema note: "Gen4-VERIFIED (motion_capture.jsonl + gyro_calib 720deg rotations): 100 samples/axis @~100Hz (1 pkt/s)... ACCEL scale 1/4096 LSB/g (sphere-fit |g|=0.99, residual 0.0%). GYRO ... +/-2000 dps ... verified via controlled 720deg rotation."Strand/BLE/Commands.swift,sendR10R11Realtime(63): "The REAL control for the type-43 'R10/R11' realtime-raw stream (payload [0x01]=on / [0x00]=off). STOP_RAW_DATA(82) does NOT affect it... Verified on-device: 2.1/s → 0/s." The connect path deliberately silences the flood for airtime/battery.PostHooks.swift(imu kind): each axis block is reduced to a per-axis mean;FrameRouter.swiftkeeps only the HR byte from type-43. Nothing banks the samples.
Community, WHOOP 5/MG (real hardware, fd4b service) — Sophonbot0/whoop-vault:
- Delivered-tables list: "
ble_accel— raw accelerometer (XYZ, g) — custom 0x2B REALTIME_RAW"; commands doc: "81 START_RAW_DATA — raw accel" (docs/ble-raw-extraction.md). - Their 0x2B decoder unpacks the whole payload as consecutive int16-LE XYZ triples (N samples per frame), scale 8/32768 g/LSB (decoders.py). The effective sample rate (N × frame cadence) is not documented — one annotation says "(1Hz)", which may be frame cadence rather than sample rate; on Gen4 the same 1-frame/s cadence carries 100 samples/axis. First thing a probe should measure.
- Feature table (README): "REALTIME_IMU_DATA_STREAM (packet 51): TOGGLE_IMU_MODE returns SUCCESS but no stream materializes. Motion data IS available at 1 Hz inside historical K18 chunks." Also: "R10/R11/R20 raw PPG: Toggle accepted, no stream emitted."
- Their command enum carries
TOGGLE_IMU_MODE_HISTORICAL = 105andGET_RESEARCH_PACKET = 132with no published probe results for either.
In-tree, 5/MG blockers (why NOOP sees none of this today):
BLEManager.send()whoop5 allowlist: 63/81/82/106 all dropped — "no WHOOP 5/MG framing for this command yet". (The puffin command transport itself is proven, iOS: liquid Today honours the Day-cycle background toggle (closes #16 parity gap) #17; the R22 deep-data opt-in already writes SET_CONFIG through it, Today "Rest" ring stuck on "Calibrating — needs a tracked night" after strap re-add: resolvedSeries callers never thread the active strap id #174.)- 5/MG motion today = ~1 Hz gravity + the cumulative step counter from the historical stream (
HistoricalStreams.swift) — the ceiling Coarse workout-type classifier (advisory, opt-in) in StrandAnalytics #414's classifier works within.
Questions
- Any objection to a gated probe (below)? It follows the existing
PuffinExperiment/RebootProbeVariantpatterns and changes nothing by default. - Does anyone have a decrypted HCI/btsnoop capture of the official app on a 5/MG during a workout? That settles whether the official app ever pulls high-rate motion over BLE — the same capture method that pinned the 4.0 alarm frame (ci(i18n): audit main pushes, not just PRs into it #535). judes.club's published work covers connect/config, not a motion window.
- Has anyone probed 105 (
TOGGLE_IMU_MODE_HISTORICAL) on any hardware? Banked IMU in the historical buffer would avoid the BLE-airtime cost entirely (flash-budget caveat acknowledged).
Proposal (all opt-in + reversible; a real 5/MG is on hand here for testing)
- Probe, behind
PuffinExperiment.isEnabled, one lettered candidate per attempt with correlatable log tags (the reboot-probe pattern): puffin-framed 81[0x01]→ 30 s frame-type/size census (counts, payload lengths, inferred samples/frame; no persistence) → disarm (82 /[0x00]). Then 63, then 105 (+ one bounded offload to inspect what lands in history), then a 132 read. - If the 0x2B accel stream confirms: a bounded capture via the existing
beginRawCapturewindow to pin the 5/MG layout (samples/frame, cadence, scale vs whoop-vault's ±8 g assumption), then a schema variant + decode PR — default-off instrumentation per the derived-signal rule. - Separately (4.0, independent of the 5/MG question): the raw stream is already in the air and discarded; an opt-in bounded banking of raw windows would give 4.0 owners labeled-capture material for HAR/sport-ID research — the upgrade path Coarse workout-type classifier (advisory, opt-in) in StrandAnalytics #414's
WorkoutTypeClassifyingseam was designed for.
Battery/airtime/flash caveats are acknowledged up front — arming stays scoped and opt-in; nothing default-on changes (the connect path today deliberately silences the 4.0 flood, and that stays).
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
vishk23 commentedon Jul 14, 2026
On-strap probe results (WHOOP 5.0/MG, fw 50.40.1.0)
Ran a gated, one-shot BLE probe (
PuffinRawProbe, auto-fires once on the puffin data path, writes a JSON census to a plain file —UserDefaultswrites never flush reliably off-device, FYI) across several iterations on a real 5.0. Full findings below; the short version is that raw high-rate motion is recorded and reachable on the 5.0, but only via the offload path, not a live stream.1. The naive 4.0-style raw commands do nothing on the 5.0
Phases: baseline →
START_RAW_DATA(81) →SEND_R10_R11_REALTIME(63) →TOGGLE_IMU_MODE(106) →GET_RESEARCH_PACKET(132). Each command got aCOMMAND_RESPONSE(0x24) — but not one produced any stream. Every phase showed onlyt28HR + the ack. Not2B, not33. So the raw commands (incl. 81, which whoop-vault uses) are accepted-but-inert on this firmware with R22 off. (Minor: 81's response was 24 B vs 20 B for the others — treated specially; worth decoding.)2. R22 unlocks a deep-packet stream (type 0x2F)
Sending the 16-flag
enable_r22_*SET_CONFIG(0x78) sequence (Whoop5Config) was accepted 16/16 (encryptedBond=true,worn=true). After that, type-0x2F "deep" packets flow in three sizes, each stamped with a unix second at frame offset 15 and arriving one-per-second with +1 s between consecutive same-size frames:t2Ft2Ft2FSo the big buffers are tens-of-Hz, not 1 Hz. Byte analysis of the 2140-B payload: 11-byte header,
ts@15, then a dense body. Accelerometer content is present — int16 triples sphere-fit to ~1 g at scale 1/4096 (the exact 4.0 accel scale the schema pins as "Gen4-VERIFIED"). The sample encoding looks int32-ish (value + zero-i16, stride 4) rather than tight i16 xyz; not fully pinned yet.3. The catch: it's the OFFLOAD BURST, not a live feed
The deep stream is connect-time offload — it dumps hard for ~15 s then stops when caught up (the burst shrank run-over-run as history synced: ~2 min → 15 s → 3 s → 0). A short, fresh sync ships only the small 1 Hz records (
t24/L84); the high-rate 2140-B buffers only ride along when there's a larger backlog to offload. There is no on-demand "wave for 30 s → pull the bytes" path, which makes a byte-perfect decoder hard to capture on demand — it needs many(raw buffer, wall-clock)pairs accumulated across days.4. What NOOP does with them today
The historical decoder pulls the 1 Hz gravity vector out of these and discards the high-rate remainder. So the tens-of-Hz motion is arriving and being thrown away.
Proposed direction (instrument first, decode later)
A passive
PuffinDeepBufferLog(Swift; twin of the existingPuffinEventLog) that keeps ONLY the big (≥1 KB) type-0x2F buffers — raw hex +strap_ts+ offload flag — in its own JSONL that the bulk-capture eviction never churns. Gated on the existing capture toggle, read-only w.r.t. the strap. Over days of ordinary wear + labeled activity that accumulates enough to reverse the layout offline, after which the high-rate accel can be decoded and stored as a real motion stream. I'll open a PR for it.Open questions for anyone with a 5/MG + captures
vishk23 commentedon Jul 14, 2026
Opened #454 — the passive
PuffinDeepBufferLogrecorder, so any 5/MG owner can flip the capture toggle and accumulate the raw high-rate type-0x2F buffers. On-device (fw 50.40.1.0) one offload captured 1424 of the 2140-byte buffers (~23 min of tens-of-Hz data).Decode status (honest): genuinely not cracked yet. The 2140-byte buffer is a fixed ~46-byte header (u32 counter @11 + strap unix ts @15 + config) then a dense multi-channel body where ~575/2140 bytes change per second. It's optical-heavy (multiple smooth channels — PPG / SpO₂ / the MG blood-pressure vascular suite), with accelerometer present but interleaved. Every naive encoding I tried (int32 / float32 / 24-bit-4-channel) produced structure that turned out to be template or buffer-boundary artifacts, not clean sensor streams. It needs (1) accumulated still / labeled data (sleep gives the clean 1 g accel signature), (2) a ground-truth reference (known HR/SpO₂ at capture time) to validate candidates, and (3) ideally the judes.club / Asherlc-dofek R22 packet field maps rather than re-deriving from scratch.
If anyone has those references, or a 5/MG and is willing to run #454 and share buffers, that's the fastest path to decoding SpO₂ / BP / high-rate motion the strap records but neither the app nor NOOP currently surfaces.
vishk23 commentedon Jul 14, 2026
Decoded the IMU buffer (with Asherlc/dofek's
docs/whoop-ble-protocol.mdas the reference — thanks). The 1244-byte offload buffer (of the two big type-0x2F sizes) is the full 6-axis IMU at ~100 Hz:Validation on 1423 captured buffers (fw 50.40.1.0):
So gyro is available on the 5.0 — via the offload, not a live stream. The earlier "packet-51 / cmd-106 is firmware-refused" holds for the live path, but the offloaded raw buffer carries full accel + gyro. This is the 100 Hz 6-axis input needed for real activity/sport classification.
(The other big buffer, 2140 B, is a separate optical suite — PPG/SpO₂ and, on MG, the blood-pressure vascular channels — still undecoded. #454's recorder captures both.)
Next: a pure
WhoopProtocoldecoder for the 1244 layout + tests, then store the IMU samples. Happy to send that as a follow-up PR.vishk23 commentedon Jul 14, 2026
Status + where help is most useful.
Two PRs up:
PuffinDeepBufferLog, the passive recorder (any 5/MG owner can flip the capture toggle and accumulate the raw buffers).Whoop5RawImudecoder (1244-byte buffer → 100 Hz 6-axis, validated against a real buffer) +ImuFeatureExtractor(cadence/energy/jerk/gyro). Pure, tested.So the IMU half is done: capture → decode → activity features, validated on hardware (gravity shell, 0.79 accel↔gyro correlation, 1.79 Hz ≈ 107 steps/min recovered on real wear). What remains for sport auto-detection is a classifier trained on labeled activity — that just needs a few
(raw IMU, known activity)sessions, which the recorder now gathers.Where the community can move the needle — the optical buffer (SpO₂ / blood pressure):
The other big offload buffer (2140 B, also type-0x2F) is a separate optical suite — multiple smooth channels consistent with multi-wavelength PPG (green/red/IR), i.e. the raw data behind SpO₂ and, on MG, the blood-pressure vascular estimate. It is not decoded. Black-box attempts on it kept hitting template/boundary artifacts (unlike the IMU, which the dofek reference cracked). It needs:
0x2Foptical layout from an APK decompile, please share — that's the missing key.Recorder #454 saves the 2140 buffer too, so it's accumulating regardless. Happy to send the raw captures to anyone reversing it.
digitalerdude commentedon Jul 14, 2026
Re your open question 3 (does the official app ever pull the high-rate motion) — a data point from the other side, using my HCI captures in #103.
Those captures are of the official iOS app doing a full history sync on a WHOOP 5.0 (fw 50.40.x), decoded in Wireshark. Across 38,304 type-0x2F records (~10 h of backlog,
record_indexgapless), every single one is a 124 B v18 or 88 B v26 frame —frames >200 B = 0in both captures. None of the big 1244/2140 B buffers you're seeing appear anywhere in the official app's offload.That lines up cleanly with your finding that the deep buffers only start flowing after the 16-flag
enable_r22_* SET_CONFIGsequence: on your strap NOOP sends that opt-in, so the strap ships the high-rate type-0x2F buffers; the official app (at least in a plain history sync) never requests them, so its offload stays all-small v18/v26. So the big buffers look like they're gated behind the R22 opt-in NOOP writes, not part of a normal sync — which is consistent with your accepted-but-inert result for the naive 81/63/106 commands.Two honest caveats so this isn't overread:
.pklgwas a rolling ~5 MB buffer, so the first ~5.5 h of backlog aged out beforesysdiagnose; but the 8.3 h that survived is uniform (no large record among ~30 k contiguous records), so the "all v18/v26" conclusion holds for what was captured.Net: the R22 deep buffers appear to be something NOOP's opt-in specifically unlocks, not traffic the official app generates on a normal offload. Happy to re-check a specific frame type against the captures if that's useful for pinning the 2140 B optical layout.
vishk23 commentedon Jul 14, 2026
Checked judes.club's 5.0 writeup (the definitive 5.0 protocol crack + interactive spec/playground). It confirms and clarifies:
hr_ch_switching), and accel x/y/z as float32 @37/41/45. Our offload 1244-B variant is a different r22 sub-variant — i16 columnar 100 accel + 100 gyro — decoded in feat: decode WHOOP 5/MG raw 6-axis IMU offload buffer + activity features (#423) #455 and gravity-shell-validated.So the SpO₂/BP frontier is well-scoped now: get a labeled cover/uncover capture of the 2140-B buffer. The recorder (#454) already saves it.
vishk23 commentedon Jul 14, 2026
Optical (2140-byte) buffer — macro-structure mapped
Ran a per-byte activity analysis over 1424 captured 2140-B optical buffers (~23 min continuous wear, via the #454 recorder). This maps the buffer's structure without any ground truth — no physiological claims, just layout:
Framing (same envelope family as the 1244-B IMU):
2F@8 = r22 inner type; counteru32@11(+1/buffer); strap unix tsu32@15(+1/buffer)count=32fields @34 and @41 → 32 samples/buffer → 32 HzBody = 10 optical channel-blocks on a 200-byte period (@47 → @2048):
Each block ≈ 99 B active + ≈ 101 B zero-pad. 99 B ÷ 32 samples ≈ 3 B/sample → ~24-bit ADC samples (standard optical-AFE width; the zero-pad is likely disabled LED channels in this wear mode). The HIGH/MED/LOW activity tiers are the signature of a multi-wavelength array — green (high perfusion signal), red + IR (SpO₂ ratio), ambient/dark reference — i.e. the SpO₂ + (MG) blood-pressure optical suite.
Micro-decode is deliberately NOT attempted here. Reading blocks as 24-bit LE yields a period-4 interleave with byte-straddle artifacts — the classic fixed-N-per-record trap this repo already burned on in the withdrawn PR #194. Per judes.club (the definitive 5.0 crack), the raw optical waveform "need[s] labeled captures to finish" — it's unsolved everywhere without one.
What would crack it (one ~60–90 s labeled capture, recorder already saves 2140-B buffers): wear → still 15 s → move 15 s → cover the optical sensor 15 s → uncover 15 s → stay worn ~10 min so a backlog offloads. The PPG channel is the block that flattens under cover; its recovered rate must track the known HR. That's the #194-safe path (validate against a varying ground truth), and it turns this from RE-guessing into a clean decode.
IMU side is done: decoder + activity features in #455, recorder in #454.
vishk23 commentedon Jul 14, 2026
@digitalerdude this is a great data point, thank you — 38,304 records with
frames >200 B = 0is exactly the confirmation I couldn't get from my side alone. It lines up cleanly: the big 1244/2140-B buffers only start flowing after NOOP writes the 16-flagenable_r22_*opt-in, so they're something NOOP unlocks, not normal-sync traffic. That also explains why the naive 81/63/106 commands ack-but-stay-inert.The one thing still open (as you flagged): your captures are a history sync, not a live workout window — so whether the official app pulls high-rate motion live during exercise is still unsettled. If you ever grab an HCI capture during a workout I'd love to diff it against the R22 stream.
Taking you up on the offer: if it's easy, does the 88-B v26 frame in your captures carry a per-record optical/PPG payload (vs the 124-B v18)? I'm trying to pin whether WHOOP's own SpO₂/BP inputs ride the small v26 record or only the big 2140-B optical buffer — a byte layout of one v26 record would help a lot.
feat(ble): capture WHOOP 5/MG high-rate R22 deep buffers for research (…
feat(ble): capture WHOOP 5/MG high-rate R22 deep buffers for research (…
feat: decode WHOOP 5/MG raw 6-axis IMU offload buffer + activity feat…
feat(ble): decode the captured WHOOP 5/MG IMU buffer inline in the de…
feat(ble): decode the captured WHOOP 5/MG IMU buffer inline in the de…
feat(android): Kotlin twin of the WHOOP 5/MG R22 deep-buffer + 6-axis…
docs: v9.0.0 release notes + changelog
33 remaining items
Amortize the v18 aux retention sweep instead of running it per insert…
vishk23 commentedon Jul 27, 2026
v20 (2140-B) optical record — complete byte layout, all 2,140 bytes accounted for
Follow-up to the block-structure comment above. The layout is now pinned to the byte, re-derived from scratch against our own capture: 29,203 records of the 2,140-B type-
0x2Fbuffer (WHOOP 5.0/MG, fw 50.40.1.0, via the #454 recorder). A validator runs 54 structural assertions over all 29,203 records — 0 failures — and accounts for 2140/2140 bytes with none left over.Everything below is from that capture. I re-ran every number in this comment today before posting. Where something is inferred rather than measured, it says so.
One correction to my earlier comment, and one to the community record, are in §5. If you built anything off the 2026-07-17 post, read that section first.
1. Lead with the validation, because the byte map rests on it
Decode block 0's first reading array as signed 20-bit in Int32 LE, take 60-second windows, cubic detrend, Hann, FFT peak in 0.7–3.3 Hz, compare against our own reference HR. Only windows where the reference is stable (sd ≤ 3 bpm) are scored.
Resting windows, reference HR 45–75 bpm, n = 55:
readings_a(abs 47)readings_b(abs 247)readings_b(abs 1935)readings_a(abs 1735)readings_a— drive = 0readings_b— drive = 0Block 3 is a physical negative control, not an eyeballed waveform. It is an active, sampling block — 25 samples per record, real nonzero readings — whose two drive fields are both zero, and whose DC level is ~630× lower than block 0's (243 vs 152,516). Same decoder, same records, same estimator: 7.3 % versus 63.6 %. That is the strongest single line of evidence here, because it isolates illumination rather than byte alignment.
Two honest notes:
2. Record framing
26 + 5×422 = 2136. CONFIRMED, n=29,203.The payload boundary is pinned by the CRC:
Header fields that are CONFIRMED (n=29,203 unless noted):
0xAA21321236at the same offset0x2F0x14= 200x15=21 in the 1244-B IMU file,0x12=18 in the v18 framesstrap_tsin 29,203/29,203 — this confirms the reading already in the thread. Its top byte is constant0x6Aacross this capture.00 19 00 00Sample rate is 25 Hz, confirmed without relying on any header byte: the counter at 11:14 advances exactly +1 per record, and each active block carries exactly 25 samples. Independent confirmation of the 50→25 fix in #545.
3. Block layout — 21-byte head + 200 + 200 + 1 = 422
The head shape
[count:1][shared:6][group_a:7][group_b:7]from my earlier comment is confirmed. Here are the fields inside it. Eleven fields consume exactly 21 bytes with no residue (1+1+2+1+2+1+4+2+1+4+2 = 21).sample_countsource_adrive_asource_bdrive_bdetector_a_selectrange_aoffset_adetector_b_selectrange_boffset_breadings_a(25 used)readings_b(25 used)The two detector groups are structurally symmetric: 7 bytes each, identical internal shape
1 + 4 + 2. In block 0 bothrangefields read 32, which is what makes the 7-byte stride visible in a raw hex dump.Per-field evidence:
sample_count@rel 0 —{25, 0, 0, 25, 25}for blocks 0–4, constant over the whole corpus. Where it is 0 the block's entire 400-byte reading area is exactly zero; where it is 25, exactly the first 25 Int32 slots of each array are populated.drive_a@rel 2:3 — the strongest field confirmation in the map. In block 0 this u16 LE takes exactly six values,{1150, 1400, 1750, 2200, 2750, 3350}, in 29,203/29,203 records — the set already reported in this thread — with no other value ever observed.drive_b@rel 5:6 —drive_b == 2 × drive_ain 29,203/29,203 records of block 0 (3350→6700, 2750→5500, 2200→4400, 1750→3500, 1400→2800, 1150→2300). This is the "2× echo", now located to an exact offset. Blocks 1–4 havedrive_b == 0.source_a@rel 1,source_b@rel 4 — constant per block in all records:source_a= 1, 3, 2, 2, 2 for blocks 0–4;source_b= 4 in every block. The alignment is forced bydrive_a— shift by one byte and the six-value match is destroyed.range_a@rel 8:11,range_b@rel 15:18 — u32 LE = 32 in blocks 0, 2, 3, 4 and 16 in block 1; the three high bytes are zero in 29,203/29,203 records of every block, so the u32 width holds.offset_a@rel 12:13,offset_b@rel 19:20 — i16 LE; every observed value in every block is a multiple of 800, from{0, 800, 1600, 2400}, in 29,203/29,203 records.Per-block configuration, as decoded (constant over n=29,203 unless marked varies):
sample_countsource_adrive_asource_bdrive_bdrive_avariesdetector_a_selectrange_aoffset_adetector_b_selectrange_boffset_bTwo measurements worth recording, both left uninterpreted:
sample_count = 25and real readings, but both drive fields zero and a DC level ~630× below block 0's. Whatever it is for, it is a sampling channel with no illumination, and it is the negative control in §1.*_selectbytes differ (blk0 3/4, blk3 3/4, blk4 3/1, blk1 and blk2 1/2), and in block 4 the two slots sit at very different DC (109,183 vs 8,208) despite sharing one block configuration. This is consistent with the "two detectors of one measurement" model from my earlier comment. It does not prove it — INFERRED, not confirmed.4. Reading encoding: signed 20-bit in Int32 little-endian
CONFIRMED by four independent tests that agree:
readings_asamples the maximum is exactly 524,287 = 2¹⁹ − 1, hit in 11,458 samples (1.57 %) and never exceeded; the minimum is −148,044 and nothing ever goes below −2¹⁹. A positive rail sitting exactly on the signed-20-bit maximum is a saturation signature.A trap worth publishing, because it nearly cost me the alignment. A −1-byte shift (starting at rel 20) produces identical seam, autocorrelation and spectral metrics — shifting one byte left is approximately multiplication by 256, and all three metrics are scale-invariant. It cannot be refuted by signal quality at all. It is refuted structurally: under a rel-20 start, absolute byte 146 would be the first pad byte and must be zero, but it is nonzero in 8.71 % of records; and rel 20 takes only 4 distinct values, which cannot be an ADC least-significant byte, whereas rel 21 takes all 256. If you are byte-hunting in these buffers, do not trust a scale-invariant metric to pick between adjacent alignments.
5. Corrections to the record
(a) The CRC input range — please re-check your implementations. My 2026-07-17 comment described the five blocks as filling
[26:2136]"where the CRC32 sits". The tiling is correct; the CRC input range is not[26:2136]. The CRC is computed from byte 8 — the record-class byte — through 2135:This is consistent with @digitalerdude's independent v26 result earlier in this thread (
u32 LE @84..87validates as CRC-32 over bytes8..83for all 1,080 records). Same frame family, same rule: the CRC starts at byte 8. That cross-format agreement is what makes me confident this is the general rule rather than a v20 quirk.(b) "~24-bit ADC samples", and the "two count=32 fields @34 and @41 → 32 Hz" from my 2026-07-14 structural comment. Both wrong, and now explained rather than just withdrawn:
range_aandrange_bin block 0, both = 32, sitting exactly 7 bytes apart because the two detector groups are 7-byte structures of identical shape. The "32" was a value, not a sample count. Real rate is 25 Hz (already corrected in WhoopProtocol: fix v20 (2140 B) historical decoder sample count 50 → 25 #545, and independently reconfirmed here from the 1 Hz counter).(c) The
{1150…3350}2× pair and the 800-step field are two different fields. Both are now pinned to exact offsets, and they should not be conflated: the 2× pair isdrive_a@rel 2:3 /drive_b@rel 5:6 (steps of 250/350/450/550/600), while the 800-step family{0, 800, 1600, 2400}belongs tooffset_a@rel 12:13 andoffset_b@rel 19:20. Different fields, different positions, different step families.Also refuted in this pass:
{1150…3350}set lives in the 26-byte record header." REFUTED. It lives at block-relative 2:3. A u16 LE scan of header offsets 0–24 produced exactly one apparent hit, at abs 15:16 — and that is a false positive: abs 15:16 is the low half of the strap unix timestamp, which takes 29,200 distinct values across this capture and therefore lands on each of the six values occasionally (6 hits total in 29,203 records). A textbook coincidence, and a good reminder to check a candidate field's cardinality before believing a value-set match.drive_abehaves as a simple AGC on the block's own DC level. NOT ESTABLISHED. Rank correlation with block-0 DC is only +0.377 and is non-monotonic — atdrive_a = 1150mean DC is negative (−14,641), at 1750 it is 272,166 with 37 % saturation, at 3350 it is 152,886 with 0.95 % saturation. The field co-varies with signal level; the control law is not characterised.6. Still open — stated as open
drive_*. The value family is exact; whether it is a current, a DAC code, or a register field is not established.source_*values{1, 2, 3, 4}select. This is the target of §7.range_*= 32 (16 in block 1) — units unknown.offset_*multiples of 800 — the field is exact, its meaning is not.00 19 00 00. All four are constant across all 29,203 records, so the field boundaries cannot be separated empirically — one of them is 25, which matches the sample count, but which byte is what is unresolvable from this corpus. The 1244-B IMU record cannot arbitrate (each candidate alignment "works" for exactly one of the two record types, which is what you'd expect if they simply have different payload structures). Note 25 Hz is confirmed anyway from the counter, independently of these bytes.drive_a= 12750 and 6650, values outside block 0's family, while being disabled.7. Experiment protocol: identifying an infrared emitter — and the limit of what it can prove
The obvious next move is to toggle
ir_hw_switching(one of the 16 R22 config flags NOOP already sends) and watch the record. That is worth doing, but only if it is designed against the baseline drift, and only if the conclusion is stated correctly. Both of those are easy to get wrong, so here is what the corpus says.7.1 Which bytes are admissible evidence
Never changed once in 29,203 records — a change after the toggle is attributable to the toggle:
sample_countsource_asource_bdrive_a(blocks 1–4)drive_b(blocks 1–4)detector_a_selectdetector_b_selectrange_arange_boffset_a/offset_b(blocks 1–4)Drifts on its own — NOT admissible as sole evidence. These moved during the baseline with no intervention at all:
drive_adrive_bdrive_aexactly (2×)offset_a{0, 800, 1600, 2400}offset_b{0, 800, 1600, 2400}Because block-0
drive_aholds a value for a median of 423 consecutive records, any phase shorter than ~500 records cannot distinguish a flag effect from ordinary drift. That sets the minimum phase length.7.2 Protocol
Record the full 11-field configuration of all five blocks in each phase, plus the DC mean of each active block's two reading arrays.
7.3 Bytes to watch, ranked
sample_countbytes of the two disabled blocks.0 → 25means the flag activated a previously configured-but-off measurement. Highest-value outcome and unambiguous: those bytes were 0 in 29,203/29,203 baseline records.source_a= 3) and abs 871 (blk2source_a= 2). Block 1 is the most interesting single target in the record: it is the only block using source 3, it is disabled, and it carries an anomalousdrive_aof 12,750.source_b, universally 4. Any deviation is meaningful.*_selectbytes, i.e. detector routing.Acceptance criteria: specificity (the change must be in an admissible byte — a change confined to 28:29, 31:32, 38:39 or 45:46 proves nothing); reversibility (returns to baseline in Phase C); persistence (holds ≥ 500 consecutive records); ambient invariance (block 3's DC at abs 1313 / 1513 comparable across phases, showing ambient light didn't change between captures).
7.4⚠️ The caveat that matters more than the protocol
Watching a byte change when a feature flag is toggled proves only WHICH REGISTER THE FLAG WRITES. It never proves the emitted wavelength.
If everything above holds, the claim you have earned is: "this flag controls byte X, which selects an emitter path." That is real and worth publishing. It is not a wavelength.
This is exactly the reasoning failure that produced the retracted
R5 = IR / R6 = redclaim in this thread. Structural position plus a suggestive name was treated as identification. It was wrong.ir_hw_switchingis a string in a config table, not a measurement — do not let a flag's name do evidentiary work.Confirming infrared requires an independent optical measurement:
State the byte result and the optical result as two separate findings. And to be explicit about where this leaves SpO₂: three active measurements of unknown colour means there is still no demonstrated red/IR pair, so there is no ratio-of-ratios and SpO₂ is not "one calibration away" — the measurements have to be identified first.
Reproducing
Everything above comes from the 2,140-B buffers the #454 recorder already saves — no new capture tooling. The two checks worth running first against your own corpus, because they are one line each and they either hold or they don't:
If you have a 5/MG and the deep buffers flowing, I'd particularly like to know whether your
sample_countpattern is also[25, 0, 0, 25, 25]and whether yoursource_avalues are1, 3, 2, 2, 2— a second device would tell us how much of the §3 configuration table is per-device state versus fixed firmware layout, which is the one thing 29,203 records from a single strap cannot answer.Thanks again to @digitalerdude — the v26 CRC-from-byte-8 result is what corroborates §5(a) across two frame sizes.
feat(ble): capture WHOOP 5/MG high-rate R22 deep buffers for research (…
feat: decode WHOOP 5/MG raw 6-axis IMU offload buffer + activity feat…
feat(ble): decode the captured WHOOP 5/MG IMU buffer inline in the de…
feat(android): Kotlin twin of the WHOOP 5/MG R22 deep-buffer + 6-axis…
docs: v9.0.0 release notes + changelog
Whoop5Config: correct enable_sig12 value to ASCII '1' (0x31) (ryanbr#522
WhoopProtocol: fix v20 (2140 B) historical decoder sample count 50 ->…
v20 optical: Kotlin RawImu length-gate parity + restore the RE eviden…
Persist the WHOOP 5/MG raw-IMU offload buffer (ryanbr#423) (ryanbr#675)
WHOOP 5/MG: passive optical-block phase experiment harness (ryanbr#862)
feat(protocol): bank the v18 per-second fields the storage funnel was…
Amortize the v18 aux retention sweep instead of running it per insert…
Decode the v20 optical record's eleven config fields, and CRC-gate it (…