Skip to content

[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

Open

Description

@vishk23

TL;DR

  • On WHOOP 4.0, the type-43 REALTIME_RAW_DATA stream carries genuine ~100 Hz 6-axis raw IMU, already fully decoded in-tree (whoop_protocol.json variant 1917, "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 a ble_accel table ("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 — and GET_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_DATA variant 1917: 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.swift keeps 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 = 105 and GET_RESEARCH_PACKET = 132 with no published probe results for either.

In-tree, 5/MG blockers (why NOOP sees none of this today):

Questions

  1. Any objection to a gated probe (below)? It follows the existing PuffinExperiment / RebootProbeVariant patterns and changes nothing by default.
  2. 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.
  3. 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)

  1. 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.
  2. If the 0x2B accel stream confirms: a bounded capture via the existing beginRawCapture window 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.
  3. 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 WorkoutTypeClassifying seam 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).

Activity

  1. vishk23 commented on Jul 14, 2026

    @vishk23
    Author

    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 — UserDefaults writes 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 a COMMAND_RESPONSE (0x24) — but not one produced any stream. Every phase showed only t28 HR + the ack. No t2B, no t33. 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:

    frame size contents (inferred)
    t2F 124 B ≈ one 1 Hz record
    t2F 1244 B ≈ 32 sub-records / second
    t2F 2140 B ≈ 59 sub-records / second

    So 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 existing PuffinEventLog) 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

    1. Exact 2140-B record layout — is the accel int32 or packed i16, and where are the other sub-blocks (PPG? gyro? temp)?
    2. Does the strap keep only ~minutes of high-rate in flash (older aged to 1 Hz), or is a long-backlog offload full-rate throughout?
    3. Anyone have a decrypted HCI capture of the official app on a 5/MG during a workout — does it ever pull high-rate motion live, or only on offload like this?
  2. vishk23 commented on Jul 14, 2026

    @vishk23
    Author

    Opened #454 — the passive PuffinDeepBufferLog recorder, 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.

  3. vishk23 commented on Jul 14, 2026

    @vishk23
    Author

    Decoded the IMU buffer (with Asherlc/dofek's docs/whoop-ble-protocol.md as the reference — thanks). The 1244-byte offload buffer (of the two big type-0x2F sizes) is the full 6-axis IMU at ~100 Hz:

    frame = 8-byte envelope + payload:
      countA @24 = 100
      accel:  ax @28   ay @228  az @428    (100 × int16 LE each, COLUMNAR)   scale 1/4096 g/LSB
      countB @630 = 100
      gyro:   gx @640  gy @840  gz @1040   (100 × int16 LE each, COLUMNAR)   ~16.4 LSB/(°/s), ±2000 dps
    

    Validation on 1423 captured buffers (fw 50.40.1.0):

    • Accel: 100% gravity shell — every one of the 100 samples/frame within ±15 % of the median magnitude, median 4120 → 1.01 g at 1/4096; magnitude 4117 ± 11 across 200 buffers (rock-stable gravity) while the axes vary.
    • Gyro: near-zero at rest (mean |v| ≈ 30), spikes to ~1174 in motion, 0.79 correlation with accel motion — two independent sensors agreeing on movement.

    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 WhoopProtocol decoder for the 1244 layout + tests, then store the IMU samples. Happy to send that as a follow-up PR.

  4. vishk23 commented on Jul 14, 2026

    @vishk23
    Author

    Status + where help is most useful.

    Two PRs up:

    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:

    1. The R22 / type-0x2F optical packet field map — offsets, channel order, sample count, encoding. dofek documents the IMU packet but not this one. If anyone has judes.club's frame-builder internals for the R22 optical variants (v2/v3/v4/v5/v6/v8), or the 0x2F optical layout from an APK decompile, please share — that's the missing key.
    2. Ground-truth pairs: a 5/MG capturing the 2140 buffer while a known SpO₂ (or the app's BP reading) is on screen, so a candidate decode is checkable.

    Recorder #454 saves the 2140 buffer too, so it's accumulating regardless. Happy to send the raw captures to anyone reversing it.

  5. digitalerdude commented on Jul 14, 2026

    @digitalerdude

    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_index gapless), every single one is a 124 B v18 or 88 B v26 frame — frames >200 B = 0 in 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_CONFIG sequence: 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:

    • My captures cover a history sync, not a workout window, so they don't settle the live-during-exercise half of your Q3 — the app could still pull motion live in a mode I didn't capture.
    • The .pklg was a rolling ~5 MB buffer, so the first ~5.5 h of backlog aged out before sysdiagnose; 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.

  6. vishk23 commented on Jul 14, 2026

    @vishk23
    Author

    Checked judes.club's 5.0 writeup (the definitive 5.0 protocol crack + interactive spec/playground). It confirms and clarifies:

    • r22 = inner type 0x2f (matches our buffers). Their live variant is 112 B: ts @7–10, HR @14, a 2nd HR @29 (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.
    • The optical PPG waveform is explicitly NOT decoded there either — quote: the raw optical waveform (PPG) and calibrated motion "both need labeled captures to finish" (hold still → move → cover/uncover the sensor). So the SpO₂/BP optical decode (our 2140-B buffer) is an open problem across the board, and the path is a labeled optical capture (cover/uncover so the PPG channel is the one that goes flat when covered; pulse rate must match the HR profile), not more black-box guessing.

    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.

  7. vishk23 commented on Jul 14, 2026

    @vishk23
    Author

    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; counter u32@11 (+1/buffer); strap unix ts u32@15 (+1/buffer)
    • config header @20–46 (per-channel gain/LED-current-looking bytes)
    • two count=32 fields @34 and @41 → 32 samples/buffer → 32 Hz
    • CRC32 tail @2128–2140

    Body = 10 optical channel-blocks on a 200-byte period (@47 → @2048):

    block @offset active bytes activity tier
    47, 247 ~99 B HIGH (~30–38)
    469, 669, 891, 1091 ~99 B MED (~14)
    1313, 1513 ~97 B LOW (~3)
    1735, 1935 ~98 B HIGH (~25–29)

    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.

  8. vishk23 commented on Jul 14, 2026

    @vishk23
    Author

    @digitalerdude this is a great data point, thank you — 38,304 records with frames >200 B = 0 is 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-flag enable_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.

  9. 33 remaining items

  10. vishk23 commented on Jul 27, 2026

    @vishk23
    Author

    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-0x2F buffer (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:

    stream median abs err within 6 bpm within 3 bpm band-peak prominence
    blk0 readings_a (abs 47) 2.48 bpm 63.6 % 54.5 % ×114.6
    blk0 readings_b (abs 247) 5.47 bpm 50.9 % 43.6 % ×67.3
    blk4 readings_b (abs 1935) 3.50 bpm 52.7 % 43.6 % ×64.7
    blk4 readings_a (abs 1735) 15.58 bpm 43.6 % 40.0 % ×63.0
    blk3 readings_a — drive = 0 59.50 bpm 7.3 % 5.5 % ×7.8
    blk3 readings_b — drive = 0 46.28 bpm 7.3 % 5.5 % ×8.1
    CTRL Int32 big-endian 40.78 bpm 12.7 % 5.5 % ×7.8
    CTRL Int32 LE +1 byte 54.33 bpm 12.7 % 5.5 % ×7.6
    CTRL Int32 LE +2 bytes 14.35 bpm 30.9 % 21.8 % ×25.6
    CTRL 24-bit LE 29.38 bpm 12.7 % 7.3 % ×8.6
    CTRL packed 20-bit 113.00 bpm 0.0 % 0.0 % ×144.6

    Block 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:

    • I previously reported 69 % within 6 bpm using a time-domain tracker. This is an independent re-derivation with a different (spectral) estimator and slightly different window criteria; it lands at 63.6 %. Same conclusion, and I'd treat 63.6 % / n=55 as the number to quote since the method is fully specified above.
    • The phase-randomised surrogate control scores 29.1 %, because phase randomisation preserves the power spectrum and this estimator is spectral. It is a weak control here and I place no weight on it. The load-bearing controls are block 3 and the byte-misalignment rows.
    • Non-resting windows (85–130 bpm, n = 101): every stream fails, ≤ 6.9 % within 6 bpm. This reproduces the previously noted ceiling — the pulse dies above ~90 bpm and is wrist-motion limited. It is a property of wrist PPG, not of the byte map.

    2. Record framing

    [ 26-byte header ][ 5 × 422-byte block ][ 4-byte CRC32 ]
       0..25            26 448 870 1292 1714     2136..2139
    

    26 + 5×422 = 2136. CONFIRMED, n=29,203.

    The payload boundary is pinned by the CRC:

    zlib.crc32(rec[8:2136]) == struct.unpack_from('<I', rec, 2136)[0]   # 29203/29203

    Header fields that are CONFIRMED (n=29,203 unless noted):

    abs width reading evidence
    0 u8 sync 0xAA identical in the 1244-B IMU file (n=29,204)
    2:3 u16 LE payload length = len−8 → 2132 cross-format: the 1244-B file carries 1236 at the same offset
    8 u8 record class 0x2F same in v18 / v20 / v21. The CRC input starts exactly here.
    9 u8 layout version 0x14 = 20 cross-format: 0x15=21 in the 1244-B IMU file, 0x12=18 in the v18 frames
    11:14 u32 LE record counter, +1 per record 29,175 / 29,202 consecutive diffs are exactly 1; the rest are capture gaps
    15:18 u32 LE strap unix timestamp equals the recorder's own strap_ts in 29,203/29,203 — this confirms the reading already in the thread. Its top byte is constant 0x6A across this capture.
    1, 4, 5, 6:7, 10, 19, 20, 21 — framing constant or with identical value statistics in the 1244-B IMU record, so framing, not optical payload
    22:25 4 B constant 00 19 00 00 see §6 — one of these is 25, but the field boundaries cannot be separated
    2136:2139 u32 LE CRC32 as above

    Sample 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).

    rel width name blk0 abs
    0 u8 sample_count 26
    1 u8 source_a 27
    2:3 u16 LE drive_a 28
    4 u8 source_b 30
    5:6 u16 LE drive_b 31
    7 u8 detector_a_select 33
    8:11 u32 LE range_a 34
    12:13 i16 LE offset_a 38
    14 u8 detector_b_select 40
    15:18 u32 LE range_b 41
    19:20 i16 LE offset_b 45
    21:220 50 × i32 LE readings_a (25 used) 47
    221:420 50 × i32 LE readings_b (25 used) 247
    421 u8 reserved, zero in all records 447

    On the names. These are descriptive labels I chose for this write-up, from what each field is observed to do in the capture — nothing more. They carry no claim about how the firmware or the silicon refers to anything. drive_* is called that only because a block with both of those values at zero produces a dark measurement with no pulse (§1); its units are not established (§6). Rename them freely in any implementation.

    The two detector groups are structurally symmetric: 7 bytes each, identical internal shape 1 + 4 + 2. In block 0 both range fields 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_a in 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 have drive_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 by drive_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):

    blk0 (26) blk1 (448) blk2 (870) blk3 (1292) blk4 (1714)
    sample_count 25 0 0 25 25
    source_a 1 3 2 2 2
    drive_a {1150…3350} varies 12750 6650 0 200
    source_b 4 4 4 4 4
    drive_b 2 × drive_a varies 0 0 0 0
    detector_a_select 3 1 1 3 3
    range_a 32 16 32 32 32
    offset_a {0…2400} varies 0 1600 0 0
    detector_b_select 4 2 2 4 1
    range_b 32 16 32 32 32
    offset_b {0…2400} varies 0 2400 0 0
    mean DC, a / b 152,516 / 150,897 — — 243 / 267 109,183 / 8,208

    Two measurements worth recording, both left uninterpreted:

    • Block 3 is a drive-off measurement — sample_count = 25 and 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.
    • Within every block the two *_select bytes 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:

    1. Byte extent. Within each 200-byte slot the last nonzero byte over the whole corpus is 99 (blk0 a and b), 97 (blk3 a and b), 98 (blk4 a), 97 (blk4 b) — never ≥ 100. Slot bytes 100–199 are zero in 29,203/29,203 records of every block. 25 samples × 4 bytes ends at byte 99. 25 × 3 bytes (24-bit) would end at 74; 25 × 2.5 bytes (packed 20-bit) would end at 62. Both refuted by the raw zero/nonzero map alone, before any signal analysis.
    2. Record-seam continuity. On a 7,590-second contiguous run, the ratio of the median step across a record boundary to the median step inside a record is 0.98 for Int32 LE at rel 21 — the seam is indistinguishable from ordinary sample-to-sample motion. A +2-byte misalignment gives 70.1, +3 gives 548.9.
    3. Smoothness. Lag-1 autocorrelation 0.981 for Int32 LE at rel 21, versus 0.068 big-endian and 0.065 for a +1-byte shift — those two are indistinguishable from noise.
    4. Bit depth / saturation. Across all 730,075 block-0 readings_a samples 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:

    zlib.crc32(rec[8:2136])   # 29203/29203 records

    This is consistent with @digitalerdude's independent v26 result earlier in this thread (u32 LE @84..87 validates as CRC-32 over bytes 8..83 for 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:

    • 24-bit is refuted (§4): the nonzero extent ends at slot byte 97–99, not 74.
    • Absolute 34 and 41 are not counts. They are the low bytes of range_a and range_b in 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 is drive_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 to offset_a@rel 12:13 and offset_b@rel 19:20. Different fields, different positions, different step families.

    Also refuted in this pass:

    • "The {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.
    • Readings begin at rel 22. REFUTED — the 100-byte all-zero pad is exactly slot bytes 100–199, which forces the array to start at rel 21; the period-4 nonzero pattern in blocks 3 and 4 also reads the alignment off directly.
    • Big-endian readings. REFUTED — lag-1 autocorrelation 0.068.
    • drive_a behaves 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 — at drive_a = 1150 mean 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

    1. Any wavelength claim. Nothing in this corpus identifies an emission band. See §7.
    2. Units/semantics of drive_*. The value family is exact; whether it is a current, a DAC code, or a register field is not established.
    3. What source_* values {1, 2, 3, 4} select. This is the target of §7.
    4. range_* = 32 (16 in block 1) — units unknown.
    5. offset_* multiples of 800 — the field is exact, its meaning is not.
    6. Header bytes 22:25 = 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.
    7. Header bytes 6:7, 19, 20 — unidentified.
    8. Why blocks 1 and 2 carry 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:

    field absolute offsets (blk0, blk1, blk2, blk3, blk4) baseline
    sample_count 26, 448, 870, 1292, 1714 25, 0, 0, 25, 25
    source_a 27, 449, 871, 1293, 1715 1, 3, 2, 2, 2
    source_b 30, 452, 874, 1296, 1718 4, 4, 4, 4, 4
    drive_a (blocks 1–4) —, 450:451, 872:873, 1294:1295, 1716:1717 —, 12750, 6650, 0, 200
    drive_b (blocks 1–4) —, 453:454, 875:876, 1297:1298, 1719:1720 —, 0, 0, 0, 0
    detector_a_select 33, 455, 877, 1299, 1721 3, 1, 1, 3, 3
    detector_b_select 40, 462, 884, 1306, 1728 4, 2, 2, 4, 1
    range_a 34:37, 456:459, 878:881, 1300:1303, 1722:1725 32, 16, 32, 32, 32
    range_b 41:44, 463:466, 885:888, 1307:1310, 1729:1732 32, 16, 32, 32, 32
    offset_a/offset_b (blocks 1–4) 460:461/467:468, 882:883/889:890, 1304:1305/1311:1312, 1726:1727/1733:1734 0/0, 1600/2400, 0/0, 0/0

    Drifts on its own — NOT admissible as sole evidence. These moved during the baseline with no intervention at all:

    field absolute baseline behaviour
    blk0 drive_a 28:29 6 values; 13 transitions in 29,203 records; median hold 423 records
    blk0 drive_b 31:32 tracks drive_a exactly (2×)
    blk0 offset_a 38:39 4 values {0, 800, 1600, 2400}
    blk0 offset_b 45:46 4 values {0, 800, 1600, 2400}

    Because block-0 drive_a holds 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

    1. Phase A — flag OFF. ≥ 20 min (~1,200 records), worn, at rest, stable ambient light, wrist still.
    2. Phase B — flag ON. Toggle, then capture ≥ 20 min under matched conditions.
    3. Phase C — flag OFF again. ≥ 20 min. Not optional — it is the only thing separating a flag effect from a one-way state change or drift.

    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

    1. abs 448 and abs 870 — the sample_count bytes of the two disabled blocks. 0 → 25 means 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.
    2. abs 449 (blk1 source_a = 3) and abs 871 (blk2 source_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 anomalous drive_a of 12,750.
    3. abs 27 (blk0) and abs 1715 (blk4) — re-routing of the currently active channels.
    4. abs 30, 452, 874, 1296, 1718 — source_b, universally 4. Any deviation is meaningful.
    5. abs 1293 / 1294:1295 — block 3's source and drive. Block 3 is the drive-off reference; if its drive becomes nonzero, the built-in dark control is gone and the whole phase comparison is confounded. Check this before interpreting anything else.
    6. abs 33/40, 455/462, 877/884, 1299/1306, 1721/1728 — the *_select bytes, 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 = red claim in this thread. Structural position plus a suggestive name was treated as identification. It was wrong. ir_hw_switching is 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:

    • Camera test (cheapest direct proof). Most front-facing phone cameras lack a strong IR-cut filter and render 850/940 nm as a visible violet-white glow. Film the optical window during Phase A and Phase B. A diode bright on camera and invisible to the naked eye is IR. The negative result is equally informative: green emitters are directly visible to the eye, so if the emitter that lights up in Phase B is visibly green, the flag did not enable IR.
    • Filter test. Put an 850 nm long-pass (or exposed developed film negative) between the window and a white card. If the newly active block's DC counts survive it, the source is IR; if they collapse, it is visible.
    • Corroborating only, never identifying: IR penetrates tissue much better than green, so an IR channel should show higher DC for comparable drive, a markedly lower AC/DC ratio, and less degradation under wrist motion. Worth recording. Not evidence of wavelength.

    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:

    zlib.crc32(rec[8:2136]) == struct.unpack_from('<I', rec, 2136)[0]
    struct.unpack_from('<H', rec, 28)[0] in {1150, 1400, 1750, 2200, 2750, 3350}

    If you have a 5/MG and the deep buffers flowing, I'd particularly like to know whether your sample_count pattern is also [25, 0, 0, 25, 25] and whether your source_a values are 1, 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.

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