diff --git a/.github/workflows/source-hygiene.yml b/.github/workflows/source-hygiene.yml
index f7ea31469f..2c3673d8e0 100644
--- a/.github/workflows/source-hygiene.yml
+++ b/.github/workflows/source-hygiene.yml
@@ -38,3 +38,10 @@ jobs:
# point — fix the comment instead.
- name: Detached doc comments (regression gate)
run: python3 Tools/doc_comment_lint.py
+
+ # The constructed arithmetic examples that docs/PROTOCOL_SENSORS.md and PROTOCOL_ECG.md tell
+ # readers to run. Stdlib-only and finishes in well under a second, so it rides along in this
+ # already-running job on every PR rather than behind a docs/** filter, which would need a
+ # workflow or runner of its own. Fails on any assertion, so a doc edit cannot break it silently.
+ - name: Protocol doc examples (arithmetic check)
+ run: python3 docs/protocol-examples/validate_examples.py
diff --git a/docs/BLE_REVERSE_ENGINEERING.md b/docs/BLE_REVERSE_ENGINEERING.md
index 2a1cf83cae..2ee1095078 100644
--- a/docs/BLE_REVERSE_ENGINEERING.md
+++ b/docs/BLE_REVERSE_ENGINEERING.md
@@ -2,8 +2,9 @@
How NOOP talks to a WHOOP strap directly over Bluetooth Low Energy — no WHOOP cloud and no account.
This document explains how the strap's private GATT protocol was understood, how the
-frame format and checksums work, how WHOOP 4.0 ("Harvard") and WHOOP 5.0 ("puffin") differ, what each
-data stream contains, and how to extend the decoder for new packet types or sensors.
+frame format and checksums work, how WHOOP 4.0 ("Harvard") and WHOOP 5.0 ("puffin") differ, capture observations, and how to extend the decoder for new packet types or sensors.
+For current wire contracts, start with the [protocol reference](PROTOCOL.md); this page
+retains implementation history and measured observations rather than a second schema.
> **Interoperability, not impersonation.** NOOP is a companion app for a strap *you own*. It reads the
> data *your* device already records and stores it locally on *your* machine. Nothing here replicates,
@@ -25,11 +26,6 @@ projects, and the Swift code ports their findings:
| **`johnmiddleton12/my-whoop`** | WHOOP 4.0 | The `61080001…` GATT service, the `0xAA` CRC8/CRC32 frame envelope, the command numbers, and the type-40/43/47 stream layouts. |
| **`b-nnett/goose`** | WHOOP 5.0 | The `fd4b0001…` GATT service, the CRC16-Modbus header check, the static `CLIENT_HELLO` frame, and the "puffin" packet types. |
-Where a function or constant is a direct transcription, the source file says so (e.g.
-`crc16Modbus` in `Framing.swift` is noted as *"Ported verbatim from the Goose reverse-engineering"*,
-and `DeviceFamily.whoop5ClientHello` is transcribed from `GooseHello.clientHelloFrameHex`). Sensor
-scale factors and field offsets were additionally re-verified on a real WHOOP 4.0 strap (see the
-on-device verification notes embedded in `Resources/whoop_protocol.json`).
---
@@ -47,7 +43,7 @@ The reverse-engineering logic is split between a platform-pure Swift package and
| `Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift` / `HistoricalStreams.swift` | Parsed frames → durable rows (`HRSample`, `SpO2Sample`, …). |
| `Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json` | The data-driven schema: packet types, enums, field offsets, sensor scales. |
| `Strand/BLE/BLEManager.swift` | CoreBluetooth engine: scan → connect → **bond** → subscribe → reassemble → route. |
-| `Strand/BLE/Commands.swift` | The curated, **safe** command set (`WhoopCommand`) and the frame builder. |
+
| `Strand/BLE/FrameRouter.swift` | Pure decode → live UI state (HR, events, double-tap, wrist on/off). |
The `WhoopProtocol` package never imports CoreBluetooth — it exposes UUIDs as plain strings so the
@@ -60,9 +56,10 @@ macOS and iOS, not macOS-only.
## 1. The discovery approach
-WHOOP straps do not expose their physiological data through any standard BLE profile. They advertise a
-**hidden, vendor-specific GATT service** alongside the two standard ones, and the interesting data only
-flows after a quiet bonding step.
+WHOOP exposes live HR/R-R through the standard Heart Rate profile and uses
+vendor-specific GATT services for additional custom records, history and control.
+The earlier WHOOP 4 custom-channel flow below uses a bonding step; this does not
+make standard HR availability depend on the same custom handshake.
### The GATT layout (WHOOP 4.0)
@@ -86,21 +83,12 @@ which made it the reliable baseline while the custom channels were being mapped.
`2A37` as the *reliable* HR/R-R source and lets the custom streams supply everything else (see
`parseStandardHR` in `BLEManager.swift`).
-### The single confirmed-write bond
-
-The custom notify characteristics (`…0003/0004/0005`) stay silent until the link is bonded. The key
-discovery is that **one "with-response" (confirmed) write is enough** to trigger iOS/macOS just-works
-bonding — there is no PIN, no pairing UI. NOOP performs this with a benign `GET_BATTERY_LEVEL`
-(`didDiscoverCharacteristicsFor` in `BLEManager.swift`):
+### The earlier confirmed-write workflow
-```swift
-// THE BONDING TRICK: one confirmed write triggers just-works bonding.
-// GET_BATTERY_LEVEL is benign and what the Mac prototype uses.
-let bondFrame = WhoopCommand.getBatteryLevel.frame(seq: seq, payload: [0x00])
-peripheral.writeValue(Data(bondFrame), for: c, type: .withResponse)
-```
-When `didWriteValueFor` fires with no error, the link is bonded and the custom channels begin to flow.
+In the earlier client flow, successful `didWriteValueFor` completion triggered
+the next custom-channel handshake step. Write completion alone does not
+independently prove a persistent bond or successful data delivery.
A subtlety learned the hard way: `didWriteValueFor` re-fires on **every** `.withResponse` write (the
bond write, every historical request, every chunk ack), so the connect handshake is gated behind a
`connectHandshakeDone` flag — re-running `HELLO`/`SET_CLOCK` mid-offload was found to make the strap
@@ -108,17 +96,15 @@ stop serving historical data.
### The connect handshake
-Once bonded, NOOP runs a WHOOP-faithful lifecycle exactly once
+The earlier WHOOP 4 client runs this connection sequence once
(`didWriteValueFor` → handshake block):
-1. `GET_HELLO_HARVARD` (35) + `GET_ADVERTISING_NAME_HARVARD` (76) — greet the strap.
-2. `SET_CLOCK` (10) — set the strap RTC to UTC (8-byte `[seconds u32 LE][subseconds u32 LE]`).
- A *wrong-length* SET_CLOCK is ack'd but not latched, which leaves the RTC lost and the strap
- refuses to serve history — a real bug found and fixed here.
-3. `GET_CLOCK` (11) with an **empty** payload — establishes the device↔wall clock correlation.
-4. `SEND_R10_R11_REALTIME` (63) with `[0x00]` — **disables** the raw realtime flood (see §4).
-5. `GET_DATA_RANGE` (34) — read the strap's stored data window for the liveness watchdog.
-6. After a short settle, request the historical offload (`SEND_HISTORICAL_DATA`).
+
+2. `SET_CLOCK` (10) — the earlier default body is 8-byte `[seconds u32 LE][subseconds u32 LE]`.
+ A separate older WHOOP 4 observation requires a ninth zero byte. The reported
+ wrong-length/clock incident does not establish a universal ACK or history-refusal
+ rule; use the [generation-specific clock notes](PROTOCOL_WHOOP4.md#bond-handshake--connect-lifecycle-whoop-40).
+
---
@@ -172,15 +158,14 @@ total = declaredLength + 8
```
The inner record (`[type][seq][cmd][data…]`) starts at **offset 8** instead of offset 4, and the
-payload CRC32 is unchanged from 4.0. The whole 4-vs-5 difference is funnelled through one switch:
-`DeviceFamily.headerCRCKind`.
+payload CRC32 is unchanged from 4.0. The header-check choice is selected through `DeviceFamily.headerCRCKind`; command
+bodies and record layouts have further generation-specific differences.
---
## 3. WHOOP 4 (Harvard) vs WHOOP 5 (puffin)
-`DeviceFamily` (`DeviceFamily.swift`) is the single enum that captures every hardware-generation
-difference. The family-aware `verifyFrame(_:family:)` and `parseFrame(_:family:)` overloads branch on
+`DeviceFamily` (`DeviceFamily.swift`) selects the transport family. The family-aware `verifyFrame(_:family:)` and `parseFrame(_:family:)` overloads branch on
it; the `whoop4` path is byte-for-byte identical to the original no-family functions (back-compat).
| Aspect | WHOOP 4.0 (`whoop4`, "Harvard") | WHOOP 5.0 (`whoop5`, "puffin") |
@@ -203,64 +188,27 @@ AA 01 08 00 00 01 E6 71 23 01 91 01 36 3E 5C 8D
This is a fully-formed type-35 COMMAND frame with a valid CRC16-Modbus header and CRC32 trailer,
exposed as `DeviceFamily.whoop5ClientHello`.
-### Bonding and the puffin session (hardware-verified)
+### Bonding and the puffin session
-Confirmed against a real WHOOP 5 strap using the Linux capture tooling in `Tools/linux-capture/` (a
-`bleak`/BlueZ capture feeding the `whoop-decode` CLI). The notes below supersede the earlier
-"unverified on MG hardware" caveats for the connect path.
-
-**The `fd4b…` service requires an encrypted link.** Unlike WHOOP 4.0 — whose custom characteristics
-are readable after a plain confirmed write — every `fd4b` operation (subscribing to a notify channel,
-writing to `fd4b0002`) needs the link to be **bonded** first. Without a bond the operation simply
-stalls while the stack waits for an encryption that never arrives. So the 5.0 session has a step the
-4.0 does not: establish a BLE bond *before* anything else.
-
-**The bond is "just works"** — no PIN, no passkey, no OOB. On Apple, CoreBluetooth performs it
-transparently the first time an encrypted characteristic is touched (no strap screen, nothing to
-confirm). On Linux it was reproduced with standard pairing: clear any stale bond, put the strap in
-pairing mode, then pair — `Pair()` completes in ~0.2 s and the bond persists, after which connecting
-needs no further pairing. WHOOP's own guidance is to pair only through the app and *not* via the OS
-Bluetooth menu; for interoperability with a strap **you own**, an OS-level just-works bond is
-nonetheless sufficient — no app-side step is required. (The strap holds one central at a time, so the
-phone must be disconnected first; the firmware logs a `BLE Bond failure` for a contended attempt.)
-
-**How NOOP's Apple app (macOS/iOS) triggers it (v1.5):** it writes `CLIENT_HELLO` to `fd4b0002` *with response*.
-That single confirmed write makes CoreBluetooth bring up the just-works bond *before* the puffin notify
-subscriptions are attempted — without it those subscriptions are rejected with *"Authentication is
-insufficient"* and the handshake hangs at "Finishing the secure pairing handshake…" forever (issue #17).
-
-**Once bonded, the session mirrors 4.0** on the new transport:
1. Subscribe `fd4b0003/0004/0005/0007`.
2. Write `CLIENT_HELLO` to `fd4b0002`. The strap replies with two `COMMAND_RESPONSE` (GET_HELLO, cmd
145) frames carrying the device serial and a session token.
-3. Drive the strap with **the 4.0 command numbers, re-framed for puffin** (`puffinCommandFrame` in
- `Framing.swift`). Verified on hardware: `SEND_HISTORICAL_DATA` (22) starts a full historical
- offload — trim-cursor acks, `History burst success`, `Historical Dump Complete`, exactly the §5
- 4.0 mechanism; `GET_CLOCK` (11), `TOGGLE_REALTIME_HR` (3) and `SEND_R10_R11_REALTIME` (63) are all
- accepted. The puffin command set is therefore the 4.0 set on the 5.0 transport, not a new one.
-
-**`CONSOLE_LOGS` (type 50) are plaintext firmware logs.** The 5.0 emits them freely, and they narrate
-the command flow in clear text (`HELLO: Send hello packet`, `Command Send Historical Data`, `History
-burst success. Trim: 0x00000010:0001b635 (16:112181)`, `Historical Dump Complete`, `PullStats:
-Data: 5, Events: 279, Bytes: 21292`). They are a useful cross-check when mapping the rest of the
-protocol.
+
### "Puffin" packet types
-WHOOP 5.0 introduces parallel packet types that carry the same semantics on the new transport. Rather
-than decode them separately, `canonicalTypeName` aliases them onto their 4.0 equivalents so they never
-fall through to "unknown":
+The client schema includes aliases for several packet types. These are implementation
+choices, not evidence that every alias has an equivalent producer on the strap:
| Puffin type | Aliased to |
|---|---|
| 38 `PUFFIN_COMMAND_RESPONSE` | `COMMAND_RESPONSE` (36) |
| 56 `PUFFIN_METADATA` | `METADATA` (49) |
-> WHOOP 5.0 framing, the hello, the puffin aliases, the bond/session handshake and the command set
-> are implemented and now hardware-verified (see "Bonding and the puffin session" above): the strap
-> bonds, accepts the 4.0 command numbers, and performs a full historical offload, all decoding
-> CRC-valid. The 5.0 **biometric field offsets** are now mapped from real captures too — live
+> The capture above verified framing, bonding, hello and selected commands, including a
+> CRC-valid historical offload. It did not verify every command or packet alias. The
+> [transport reference](PROTOCOL_TRANSPORT.md) distinguishes observed producers from schema names. The 5.0 **biometric field offsets** are now mapped from real captures too — live
> `REALTIME_DATA` (§5) and the historical type-47 record (version 18, §5) both decode HR / R-R /
> gravity, validated against ground truth. Capture with `Tools/linux-capture/whoop_capture.py
> --history-only --history-ack` and decode with `whoop-decode`.
@@ -269,33 +217,18 @@ fall through to "unknown":
## 4. The realtime "R10/R11" raw stream (type 43)
-`REALTIME_RAW_DATA` (packet type **43**, internally "R10/R11") is the strap's high-rate raw sensor
-stream. On the WHOOP 4.0 firmware it streams **continuously and unprompted** at roughly 2 packets per
-second once the link is up — and each packet is large (~1.9 KB). Two variants have been mapped,
-distinguished by payload length (`PostHooks.swift` `raw_data` hook + the `variants` table in
-`whoop_protocol.json`):
| Payload len | Kind | Contents |
|---|---|---|
-| 1917 | `imu` | HR byte + R-R + **6 IMU axes** (accelX/Y/Z, gyroX/Y/Z), 100 signed-`i16` LE samples/axis @ ~100 Hz |
-| 1921 | `optical` | A single AC-coupled PPG waveform: ~419 `s24` LE samples @ ~437 Hz, stride 4 |
-
-The IMU variant is well-characterised and on-device-verified:
-- **Accel** scale `1/4096` g/LSB (sphere-fit `|g| ≈ 0.99`, residual 0.0%).
-- **Gyro** scale `2000/32768 = 0.06104` deg/s/LSB → full-scale ±2000 dps, verified with controlled
- 720° rotations.
-- Axes live at frame offsets `accelX@89, accelY@289, accelZ@489, gyroX@692, gyroY@892, gyroZ@1092`.
-- Roughly 36% of the frame (a header gap and a tail from offset 1292) is **still unmapped** and kept
- raw — an honest gap, not an invented field.
### Why NOOP disables it on connect
-The type-43 flood is expensive on two axes the strap can't spare:
-
-- **BLE airtime** — at ~2 × 1.9 KB/s it dominates the connection and starves the historical offload.
-- **Strap flash** — keeping the raw stream on blocks dense biometric retention and disconnected
- operation.
+In the earlier WHOOP 4 sessions described here, the ~2 × 1.9 KB/s stream
+consumed substantial BLE airtime during historical offload. Live delivery alone
+does not establish how data is retained in flash or whether disconnected recording
+continues. WHOOP 5/MG has separate [production, live-delivery and collection
+controls](PROTOCOL_CONFIGURATION.md#collection-storage-and-live-transport).
The real control is **not** `STOP_RAW_DATA` (82), which doesn't affect this stream — it is
`SEND_R10_R11_REALTIME` (63). Sending it with `[0x00]` on connect stops the flood (verified on-device:
@@ -360,10 +293,10 @@ record's **version is the `seq` byte** (`frame[5]`); the schema resolves it via
| 40/44/48 | `gravity_x/y/z` (f32) | Accel-derived gravity vector (g). |
| 55 | `skin_contact` (u8) | 0 = off-wrist (capacitive). |
| 56/60/64 | `gravity2_x/y/z` (f32) | Second accel/gravity triplet. |
-| 68 / 70 | `spo2_red` / `spo2_ir` (u16) | Raw ADC; SpO₂ % computed locally. |
-| 72 | `skin_temp_raw` (u16) | Raw ADC; °C computed locally. |
+| 68 / 70 | `spo2_red` / `spo2_ir` (u16) | Raw ADC; no calibrated SpO₂ % is established by these fields. |
+| 72 | `skin_temp_raw` (u16) | Raw ADC; physical calibration is separate. |
| 74 / 76 / 78 | `ambient`, `led_drive_1/2` (u16) | Optical config. |
-| 80 | `resp_rate_raw` (u16) | Raw; respiratory rate computed locally. |
+| 80 | `resp_rate_raw` (u16) | Legacy raw respiration-related field; not independently a rate in breaths/min. |
| 82 | `signal_quality` (u16) | DSP quality. |
Versions 5/7/9 are generic HR/R-R-only records with no DSP sensor block; version 12 shares the v24
@@ -371,41 +304,24 @@ layout; **version 25** is a different WHOOP 4.0 firmware layout (84-byte, timest
decoded in v1.95 — see "The WHOOP 4.0 type-47 record (version 25)" below).
`extractHistoricalStreams` (`HistoricalStreams.swift`) turns these into the typed rows
(`HRSample`, `SpO2Sample`, `SkinTempSample`, `RespSample`, `GravitySample`, …). The raw ADCs are kept
-as-is (`unit: "raw_adc"`) — SpO₂ %, skin temperature in °C, and respiratory rate are derived later in
-`StrandAnalytics`, on-device, never on a server.
+as-is (`unit: "raw_adc"`). These names do not independently establish physical
+calibration. Current NOOP keeps SpO₂ import-only and treats respiration estimation
+and temperature processing separately; see [current data boundaries](WHOOP5_DEEP_DATA.md#spo₂-and-respiration-interpretation-limits).
### Safe offload + trim
The strap streams `HISTORY_START → type-47 records → METADATA (HISTORY_END) → … → HISTORY_COMPLETE`.
Each `METADATA` chunk carries a **`trim_cursor`** (u32 at frame offset 17). NOOP persists the decoded +
raw rows first, then sends `HISTORICAL_DATA_RESULT` (23) as a confirmed write echoing the chunk's
-`end_data` — only then may the strap forget that chunk. This makes the offload resumable: the durable
-`strap_trim` cursor means the next session resumes exactly where the last one stopped.
-
-### Offload throughput is firmware-paced (~10 records/s), not link-bound
-
-The offload runs at a steady **~10 type-47 records per second**, and since the records are 1 Hz that is
-only **~10× real-time** (a full day ≈ 40 min, a night ≈ 30 min). This is a property of the strap
-firmware, **not** the BLE link. Measured on a real worn WHOOP 4 (`Tools/linux-capture/`), the rate did
-not move when either link parameter was forced upward:
+`end_data`. The local `strap_trim` cursor records committed client progress. It does not guarantee
+that the strap resumes at exactly that position: unacknowledged records can repeat, and preparation
+can continue after a rewind failure. Retain duplicate handling and the full eight-byte ACK token;
+see [interruption and recovery](PROTOCOL_TRANSPORT.md#interruption-and-recovery).
-- **ATT MTU 23 → 247** — a 104-byte type-47 frame goes from 6 notification packets to 1. No change.
- (BlueZ does not auto-negotiate the MTU; `whoop_sync.py` calls `_acquire_mtu()` to raise it — the
- offload still streams at ~10/s.)
-- **Connection interval 50 ms → 7.5 ms** — via BlueZ `conn_min/max_interval` debugfs, the Linux
- equivalent of Android's `requestConnectionPriority(CONNECTION_PRIORITY_HIGH)`. No change.
-
-The rate is rock-steady across the whole drain — the signature of firmware-side pacing of the per-record
-`HISTORY` stream, not transmission throughput or the per-chunk ack round-trip. **Implication:** matching
-the official app's far faster sync (24 h in 1–3 min) would require a different **bulk / flash-page
-transfer command**, not link tuning, and that command is not yet reverse-engineered. For unattended
-periodic sync ~10× real-time is fine (and resumable via the trim cursor if interrupted). It also means
-adding `requestConnectionPriority`/`requestMtu` to a client to speed *this* offload is not worthwhile.
-
-### WHOOP 5.0 historical offload (hardware-verified)
+### WHOOP 5.0 historical offload
The ack is not just for resumability on WHOOP 5 — **it is what makes the offload progress at all.**
-Confirmed on a real worn WHOOP 5 (latest firmware) via `Tools/linux-capture/`:
+Confirmed in the cited worn WHOOP 5 capture via `Tools/linux-capture/`:
- **Without acking**, the strap re-serves the *same* early chunk forever. Across 16 deterministic
re-requests the `trim_cursor` stayed frozen at `112193` and **zero** type-47 records arrived — only
@@ -420,236 +336,68 @@ On WHOOP 5 the metadata fields sit at the 4.0 offsets **+4** (the envelope shift
### The WHOOP 5.0 type-47 record (version 18)
-The historical record's version byte is `frame[9]` on WHOOP 5 (the +4 image of the 4.0 `frame[5]`).
-Real WHOOP 5 hardware on the **latest firmware** emits **version 18 (124-byte)** — **not** the 4.0
-**v24** layout documented above, and **not** v24 shifted by +4. The repo schema does not contain v18;
-this device's firmware revision simply uses a different layout, and a naive "v24 + 4" decodes to
-garbage (HR `0`, gravity overflow). The fields below were read off real frames at their **absolute
-5.0 offsets** and cross-checked physiologically, never assumed (`decodeWhoop5Historical` in
-`Interpreter.swift`, parity test `Whoop5HistoricalTests.swift`):
+Use the [canonical R18 layout](PROTOCOL_SENSORS.md#r18-biometric-summary) for
+field offsets, widths, counters, thermal values and state bits. The record
+contains both interpreted fields and raw values without a physiological label.
-| Offset | Field | Validation |
-|---|---|---|
-| 9 | `hist_version` (u8) = 18 | discriminates the layout |
-| 11 | `record_index` (u32 LE) | a per-record counter: `+1` every record and **independent of `unix`** (it advances across gaps), so a lifetime record index, not a clock. Span ≈ record count on **two** straps. `@11` is only the low byte — read the full `u32` LE. |
-| 15 | `unix` (u32) | monotonic, +1 s |
-| 22 | `heart_rate` (u8) | **matched the 2A37-verified live HR exactly at all 96 overlapping timestamps** (mean \|Δ\| 0.00 bpm); note this is v24's `21`+1, **not** +4 |
-| 23 | `rr_count` (u8) | matches #valid R-R intervals 100 % (1141/1143) |
-| 24 + 2·i | `rr[i]` (u16, 1/1024 s ticks) | WHOOP 5 firmware `50.41.1.0`: paired standard-BLE R-R confirms `ms = (ticks * 1000 + 512) / 1024` (integer rounding). Raw native payload words remain ticks; decoded intervals use ms. WHOOP 4 units are unchanged. |
-| 36 | `hr_quality_flags` (u8) | a **flag byte**, *not* the low half of a fixed-point HR. Over **18,650** real v18 records bit 4 is **never** set (0/18,650 — a genuine 8.8 fraction sets it ~50 % of the time, and it is the only bit never set), **95.02 %** of values land in `0x80`–`0x8F` (uniform would be 6.25 %) across just **40 distinct values**, and sd = **26.5** vs 73.9 for a uniform byte. **Bit 7 = validity**: with it clear (n=748) `rr_count == 0` in **70.32 %** of records vs **19.82 %** with it set, and the `@108/@109` sentinel fires in **69.65 %** vs 1.32 %. Remaining bits unpinned; carried raw. |
-| 37 | `heart_rate_alt` (u8, bpm) | a **duplicate** of `heart_rate@22` — equal in **99.575 %** of records (18,523/18,602), differing only by −6…+2, and it tracks HR only while `@36` bit 7 is set (99.74 % exact vs 94.12 % when clear). |
-| ~~36–37~~ | ~~`hr_fixed_8_8` (u16 LE) — bpm = `value/256`~~ | **Retired.** The "corr 0.989 with `heart_rate@22`" that justified this name was **circular**: the u16 is literally `hr@22` (at `@37`) plus the `@36` flag byte over 256, so the residual is a flat **+0.504 ± 0.189** — i.e. `@36/256`, not a sub-bpm fraction. On records where `@36` bit 7 is clear it produced absurd readings (a fixture decodes to **227 bpm**). |
-| 33 | `cardiac_flags` (u8) | a **beat-detection quality byte**, not cardiac. Over **18,650** v18 records (#845 census — @digitalerdude's public HCI capture plus a second strap): **bit 0 is byte-identical to `@81` bit 0** in 18,650/18,650, across two sessions 15 days apart on different hosts, so it is **not an independent signal**. Bits 1–3 are never set; bits 4–5 are **thermometer-coded** (bit 4 only ever set with bit 5; state `01` never occurs) — a 3-level field. High-nibble popcount is monotone against `P(rr_count == 0)`: **.180 / .207 / .301 / .427 / .612** for popcount 0→4, against a **.219** base rate. The name is POSITIONAL (it sits near the HR fields), not derived — the census says what the byte does, not what it is. |
-| 38 | `rr_packed` (u16) | a u16 beside the R-R fields; meaning still **not pinned**. |
-| 40 | `cardiac_status` (u8) | a **saturating 0–255 confidence score**, correlated **r = −0.80** with `@113` (#845 census). `whoop-local` names this `signal_quality` in its own code with no stated source or supporting analysis; the census independently supports something quality-shaped, but the name here stays positional until someone pins the scale. |
-| 41 | `dynamic_acceleration` (f32, g) | the strap's own **gravity-removed motion magnitude**, one scalar per second sitting immediately before the gravity triplet. Gated to `[0, 8] g` so a wrong offset stores nothing rather than garbage; reads 0.006–0.033 g across the resting oracle frames. Decoded on both platforms but **not persisted and not scored** — `step_motion_counter@57` and `activity_class@63` are what the motion paths actually consume. See the byte-43 note below. |
-| 45 / 49 / 53 | `gravity_x/y/z` (f32, g) | \|g\| ≈ 1.0 for 100 % of 500 records; v18 has **one** triplet (not v24's two) |
-| 57–58 | `step_motion_counter` (u16 LE @[57:59]) | a **cumulative** counter: climbs while moving, flat when still, low byte wraps at 256. **Steps = Σ wrap-aware diffs** `(cur-prev)&0xFFFF` — *not* the value summed per record (that over-counts massively — the WHOOP 5/MG step over-report). No per-record step count is in the record. |
-| 59 | `step_cadence` (u8) | a **cadence-like** byte between the counter and `@63`: never `0`, and lower when moving faster (still > walk > run in the data). Raw — no unit asserted. |
-| 63 | `motion_wear_quality` (u8) {0,1,2} | a 3-valued byte; kept **raw** (semantics not pinned from observation). Also read as an **activity class** (0 still / 1 walk / 2 run, #316); `whoop-local` decodes the same offset with the same `<= 2` gate, reached independently (#715). |
-| 69 | `temp_aux_1_raw` (i16 LE); °C = value/10 | a **secondary temperature channel**: tracks `skin_temp@73` (corr **0.92** on two straps) with the same on-wrist diurnal curve; deci-°C resolution. |
-| 71 | `temp_aux_2_raw` (i16 LE); °C = value/10 | a second **temperature channel**: tracks `skin_temp@73` (corr **0.97**), same diurnal behaviour. |
-| 73 | `skin_temp_raw` (u16); °C = raw / 100 | A **digital skin-temperature sensor**, identified **purely from the data**: the on-wrist warming/diurnal curve is a thermal signature nothing else in the record has. **Scale = `/100`** — the only divisor that yields a physiological worn skin temperature (median ≈ **34 °C** across two straps; `/128` reads a non-physiological ≈ 27 °C). Decoded in `decodeWhoop5Historical` (`Interpreter.swift`); flows to the decode-features store as `skin_temp_raw` + derived `skin_temp_c`. |
-| 75 | `status_word` (u16 LE) | a packed status word; **NOT a deep-sleep marker** — its low nibble is `0` across ~258k records and it occurs as often awake as asleep (the community "`80`=deep" reading is a misread). Raw. |
-| 77 | `status_word_1` (u16 LE) | raw; a near-static sibling of `status_word@75` (low nibble = channel index `1`). |
-| 79 | `status_word_2` (u16 LE) | raw; sibling of `@75`/`@77` (low nibble = `2`). |
-| 81 | `sleep_state` = `(byte >> 4) & 3` (+ low-nibble sub-flags) | bits 4-5 = the band sleep state: `0` wake / `1` still / `2` asleep / `3` up (deep/REM/light are off-band). Low-nibble sub-flags, observation-framed: **b0-1 `onwrist`** (on-wrist/validity flag) and **b2-3 `wake_quality`** (a 2-bit code observed nonzero **only in wake**); **b6-7 reserved** (`0` across all records). (Hypothesised from captures + a scored night on #132.) |
-| 82 | `aux_byte_82` (u8) | the raw carry of the byte decoded as **`spo2_candidate_82`** — a strap-computed SpO₂ % scalar, tri-mode, sleep-only (#103). Instrumentation only, never a shipped metric; see the note below and [`WHOOP5_DEEP_DATA.md`](WHOOP5_DEEP_DATA.md). |
-| 83–103 | reserved | observed **constant `0`** on two straps (zero-filled). |
-| 104 | (const) | observed **constant `1`** on two straps; carried raw, no metric. |
-| 106 / 107 | `optical_baseline_a` / `optical_baseline_b` (u8, u8) | two **independent u8** optical/ADC baseline channels — **not** one u16 LE. A u16 is structurally impossible here: across 18,599 consecutive-second pairs the **high byte changed while the low byte stayed frozen in 3,514 (18.89 %)**, and the corpus holds **zero** low-byte wrap events — a real u16 cannot step its high byte without a carry. The apparent u16 deltas are exactly `256·Δ@107 + Δ@106` (clustering at 0, ±1, ±255, ±256, ±257, ±513). Correlated but independent (corr **+0.73**; they move in **opposite** directions in 5.8 % of pairs where both move). **`0` — not `128` — marks off-wrist**: both bytes read 0 in exactly the 8 records that also carry `HR == 0`, while 128 occurs unremarkably while worn (`@106` in 10 records, `@107` in 103). Magnitudes are device-specific (102–255 / 119–247 on one strap vs 20–66 / 34–81 on another), so **no scale is asserted**. |
-| 108 / 109 | `optical_amp_a` / `optical_amp_b` (u8, u8) | a tightly-coupled **pair** (equal in 23.5 % of records, within ±2 in ~80 %). **`128` is a RECORD-level sentinel**, not per-channel: `amp_a == 128` in 757 records and `amp_b == 128` in 757 — the **same** 757, never one without the other. They do **not** rise with heart rate; that reading (~34 at HR 40–49 → ~58 at 80–89) was an **averaging artifact** of counting the 128 sentinel as a number — with sentinels excluded the trend is flat-to-declining (**32.45 → 29.52**). The real monotone trend is with **motion**: ~32.7 while still (`dyn_acc` < 0.02 g) → **37.4** at 0.05–0.2 g. The sentinel is a usable per-second **signal-quality** flag: it fires on 4.02 % of worn seconds and predicts the band's own beat-detection failure (`rr_count == 0`) at **79.44 % vs 19.40 %** — a **4.09×** lift that **survives holding motion constant** (4.11× within `dyn_acc` < 0.009 g), where shuffled and circular-shift nulls all sit at ~1.0×. **Not** SpO₂, blood pressure or a perfusion substrate — signal-quality/AGC is the supported reading and the wavelength identity is unknown. |
-| 113 | `unknown_f32_113` (f32 LE) | a **graded quality metric**, no longer unknown: it **floors at −5.2869** when signal quality is good, and across its range takes `P(rr_count == 0)` from **18.30 % to 78.00 %** — a 4.3× lift (#845 census). `0` = unset. Correlated **r = −0.80** with `@40`, so the two report the same condition on different scales. Carried raw; no physiological reading is asserted. |
-
-**Corpus caveat for the `@33` / `@40` / `@108`–`@109` / `@113` quality group.** Those figures come from the #845 census over one contiguous capture: **a single subject, one night, 5 h 10 m, 99.48 % band-scored asleep**, median `dyn_acc` 0.0073 g, with **no ambulation, no workout and no verified off-wrist period**. The cardiac-quality fields are therefore well exercised and the activity-side behaviour is barely exercised at all. Read the monotone relationships as established *for still, asleep wear* — not across wake, exercise or off-wrist, which this corpus cannot speak to. A second corpus is what would promote any of this beyond instrumentation.
-
-The strongest check on the HR offset: where a historical record and a live `REALTIME_DATA` (§5, 2A37
-ground-truth-verified) frame share a timestamp, the historical HR equalled the live HR at **96/96**
-samples — so HR@22 is anchored to hardware ground truth, not just internally consistent.
-
-A second, independent corroboration comes from **two straps on the same wearer**: a WHOOP 4 and a
-WHOOP 5 worn over the same window, both offloaded and decoded, agree at **corr 0.96** across ~28 000
-overlapping 1 Hz samples, with a **rest-only mean absolute error of 0.7 bpm** (they diverge only during
-exercise, as two independent PPG sensors do). That is a large-sample, cross-generation check on HR@22
-on top of the live-vs-historical match above.
-
-#### Byte 43 is not a respiration rate (#520)
-
-A third-party decoder reads a `u8` at payload offset 35 — **frame offset 43** — and labels it a raw
-respiration rate. It is not. Frame 43 is the third byte of the little-endian `dynamic_acceleration`
-float32 at 41, and the claim is disproved by the fixture frames already in the repo:
-
-| frame | byte 43 as "brpm" | f32@41 as g | \|g\| @45/49/53 |
-|---|---|---|---|
-| `whoop5_v18_real_worn` | 22 | 0.0092 | 1.0086 |
-| `whoop5_v18_real_one_rr` | 47 | 0.0107 | 1.0106 |
-| **`whoop5_v18_real_offwrist`** | **195** | 0.0060 | 0.9985 |
-| `whoop5_v18_real_ack_capture` | 108 | 0.0144 | 1.0074 |
-| `whoop5_v18_real_device2_hr57` | 6 | 0.0328 | 1.0029 |
-| `whoop5_v18_real_device2_hr63` | 9 | 0.0084 | 1.0096 |
-
-Two readings of bytes 41–44 are on offer, and they disagree about byte 43. The evidence, in order:
-
-1. **The 4-byte grid is fixed.** The f32s at 45/49/53 give \|g\| = 0.9985…1.0106 on every frame; three
- values landing on unit magnitude together is not something a misaligned read produces.
-2. **Read as an f32 on that grid, 41–44 is physically coherent** — 0.006–0.033 g on all six frames,
- the right size for a gravity-removed magnitude, and it stays coherent across worn, off-wrist and
- two different straps. (Being on the grid doesn't by itself make it a float; the coherence is what
- argues it is one.)
-3. **Read as a rate, byte 43 is impossible** — 6…195 "brpm", worst on the **off-wrist** frame: 195
- breaths/min from a strap nobody is wearing, with `heart_rate@22` = 0.
-
-A byte belongs to one field. (2) and (3) can't both be right, and (3) is refuted by its own values.
-
-Note the tautology to avoid: byte 43 *is* byte 2 of that float by construction, so reproducing it from
-the float's exponent/mantissa bits proves nothing. The evidence is the gravity anchor plus the
-off-wrist value. `dynamic_acceleration` is now pinned in `decoder_oracle.json` on both platforms, so a
-future offset change here fails a test rather than silently reintroducing a fabricated vital sign.
-
-#### Byte 82: already decoded as `spo2_candidate_82`, blocked on a cross-device contradiction (#103)
-
-**This byte is not unmapped, and the open question is not what it is.** `Interpreter.swift` and
-`HistoricalStreams.kt` both decode `@82` as **`spo2_candidate_82`** — a strap-computed SpO₂ % scalar,
-tri-mode (70–100 a real %, bit-7 a saturation sentinel, other sub-70 a diagnostic code), populated only
-during sleep. It is instrumentation only: a guard test
-(`testHistoricalV18OpticalFieldsAreNotNamedPhysiologically`) stops it ever writing `spo2Pct`,
-`spo2_red` or `spo2_ir`, and nothing downstream reads it. The full analysis lives in
-[`WHOOP5_DEEP_DATA.md`](WHOOP5_DEEP_DATA.md); the `aux_byte_82` row above is the same byte carried raw
-alongside it.
-
-**The evidence is split, and that is the whole blocker.** An 8-night independent validation with real
-spread reaches **corr +0.99** (~0.4 %/night), tracks both a 92 % desaturation and a 98 % high, and is
-offset-specific — only `@82` tracks in a 74–92 scan. But on the original #103 capture device, two
-checked nights moved **opposite** to the app value (app 95.50→92.83 vs gated mean 93.62→93.80).
-Unresolved: device/firmware variance, or an extraction error on one side. Until that contradiction
-resolves, `@82` stays a candidate.
-
-**What clears the bar is multi-device correlation, not one more capture** — the nightly candidate
-tracking the app's own SpO₂ across many nights on several straps, *including* the device where the two
-nights currently disagree. `Tools/linux-capture/validate_spo2_candidate.py` is the harness for exactly
-that. A single asleep frame proves nothing here; the value range has been seen.
-
-**Independent corroboration (#715).** `whoop-local` reads the same byte the same way — sleep-only, and
-its decoder applies the identical `70…100` in-band gate, reached separately. That is a second source on
-the *identification*. It does not touch the contradiction above, which is about whether the values
-track a given wearer's app figures, not about what the field is. Note also that this project's decode is
-already attributed as decompile-sourced (`gen5.rs spo2_pct`), reimplemented here as a protocol fact —
-so whoop-local is corroboration, not the origin.
-
-WHOOP 5 v18 carries no raw respiration channel, and the decoders already say so: `respRateRawOff = 80`
-is set on the **4.0** `HIST_V24` layout only (§ the type-47 biometric record), and `AnalyticsEngine`
-notes "WHOOP5 v18 carries no raw resp ADC, so this is an on-device estimate" where it derives the rate
-from RSA instead.
-
-Skin temperature @73 **is** decoded (above); PPG / SpO₂ still live further in the 124-byte record but
-lack on-device ground truth, so that region is left raw rather than guessed (project rule: real
-captures, never invented offsets). The decoded fields feed the existing `extractHistoricalStreams`
-path unchanged, so WHOOP 5 historical HR / HRV / gravity / skin-temp land in the datastore like 4.0.
+#### Unresolved physiological interpretations
+
+Frame byte 43 belongs to the R18 float at frame 41; it is not a separate
+respiration-rate field. Byte 82 remains an uninterpreted raw value. Neither its
+range nor correlation with an export establishes calibrated SpO₂. Use the
+[canonical R18 layout](PROTOCOL_SENSORS.md#r18-biometric-summary).
### The WHOOP 5.0 type-47 record (version 26) — high-rate optical PPG
-The same WHOOP 5 also emits an **88-byte type-47 record with version byte 26**, distinct from the v18
-per-second summary: a high-rate **optical PPG** waveform — **24 little-endian i16 samples at bytes
-[27:75]**, one record per second (`unix` u32 LE @15, the same slot v18 uses), i.e. a **24 Hz** trace.
-(It is little-endian — the high byte of each sample is `0xFA..0xFF` / `0x00..0x01` — not big-endian.)
-
-It is identified as PPG, not IMU/motion, using the **heart rate as internal ground truth** — no external
-reference or app export needed:
-
-- Autocorrelating the concatenated trace peaks at the HR: **lag 14 = 102.9 bpm** vs a v18-measured
- 101.7 bpm, with the half-period anti-correlation and 2-beat harmonic of a real pulse.
-- Independent trough-detection gives a **563 ms inter-beat interval (≈106 bpm)**, again matching HR.
-- The pulse stays HR-locked even in the **stillest** seconds, and its amplitude is not motion-driven
- (`corr(amplitude, |Δgravity|) = +0.35` — mild motion artifact, not the signal) — so it is optical,
- not a ballistocardiographic IMU reading.
-
-**Time-multiplexed optical channels.** Byte `frame[21]` is the channel index: the strap sweeps **26
-optical channels (values 1…26)**, one per ~40-frame (~39 s) block, revisiting a given channel only
-~20 min later — so a full 1→26 sweep is spread over hours and **no two channels are ever sampled
-simultaneously**. Each channel's waveform autocorrelates to the heart rate (lag 14 ≈ 103 bpm) with its
-own DC baseline. Which physical LED each index maps to is **not** verifiable from the data, so the raw
-index is surfaced (`ppg_channel`, gated to 1…26) with no colour claim. *(An earlier read at `frame[12]`
-— the "two channels `0x41`/`0x46`" — was a high-entropy counter byte mistaken for the channel during a
-short 2-burst capture; verified against a 22 h overnight corpus, `frame[12]` takes 67 distinct values
-while `frame[21]` takes exactly 26. The PPG **sample** decode (LE i16 @[27:75]) is unaffected and
-correct.) This 26-way time-multiplex is also why **SpO₂ is not recoverable offline** — it needs
-*simultaneous* red+IR, and no two channels are ever co-sampled.
-
-The full v26 byte map (88 bytes; CRC32 @84):
-
-| Bytes | Field | Status |
-|---|---|---|
-| 8 / 9 | type 47 / version 26 | — |
-| 10, 13, 14 | `0x80` / `0x84` / `0x01` | constant header |
-| 11 | per-record counter (+1/s) | sequence |
-| **12** | **`ppg_channel`** (`0x41` / `0x46`) | **mapped** — optical channel id |
-| **15** | **`unix`** u32 LE | **mapped** — real seconds (v18's slot) |
-| 19 | `0x000147AE` constant | config param |
-| 23–26 | high-entropy (DC / checksum?) | raw — no ground truth |
-| **27–74** | **`ppg_waveform`** 24× LE-i16 | **mapped** — 24 Hz PPG, HR-locked |
-| 75–83 | footer (random + `0x50`,`0x08` const) | raw — no ground truth |
-
-`decodeWhoop5HistoricalV26` exposes `ppg_waveform` (+ `ppg_sample_count`), `ppg_channel`, and `unix`. The
-samples are raw AC-coupled ADC counts — PPG has no absolute unit — so no scale is invented; the
-high-entropy `23–26` and the footer are left raw (no internal ground truth). Reproduce the proof with
-`Tools/linux-capture/analyze_v26_waveform.py`; parity tests `Whoop5PpgWaveformTests.swift`.
+WHOOP 5/MG emits an **88-byte type-47 record with version byte 26**. Use the
+[canonical R26 optical-window layout](PROTOCOL_SENSORS.md#r26-compact-optical-window)
+for decoding: a u32 base at frame 23 followed by 24 signed adjacent deltas at
+frame 27 reconstructs **25 samples**, subject to delta clipping. These are not
+24 independent absolute samples, and the record alone does not establish 24 Hz.
+
+The two-byte field at frame 21 is a **burst counter**, not an optical channel
+selector. Frame 12 belongs to the record index. Neither field identifies a
+wavelength; do not infer a channel sweep or SpO₂ recoverability from it.
+
+Earlier capture analysis found a pulse-related pattern that remained present in
+still periods, with amplitude/motion correlation +0.35 in the examined capture.
+The published lag-to-bpm and trough-to-milliseconds results depended on the old
+24-sample timing assumption and must not be reused as validation of the corrected
+decoder. The existing `Tools/linux-capture/analyze_v26_waveform.py` is a historical
+analysis tool; check its decoding and timing assumptions before using its output.
+No recalculation or new capture validation is claimed here. Calibrated sample
+units and physical wavelength remain unresolved.
### The WHOOP 5.0 / MG type-47 records (versions 20 & 21) — bulk multi-channel sensor stream
-Newer 5/MG firmware also serves two **large** type-47 records alongside v18/v26: **version 20 (2140 B)**
-and **version 21 (1244 B)**, emitted as a **pair per second**. Older builds had no map for them, fell back
-to "unmapped layout", and stored nothing — so the offload completed but no data landed (issue #344). Both
-reuse the v18 record header, confirmed across the captured frames:
-
-| Offset | Field | Notes |
-|---|---|---|
-| 9 | layout version | 20 (len 2140) / 21 (len 1244) |
-| 10 | `layout_marker` (u8) | `0x81` (v20) / `0x80` (v21), constant per version |
-| 11 | `record_index` (u32 LE) | monotonic +1/record — the same lifetime counter as v18 |
-| 15 | `unix` (u32 LE) | real seconds, +1 s/record (v18's slot) |
-
-Integrity is the standard trailing **CRC32** over the payload (`frame[8 : len-4]`) — it validates on every
-captured frame of both versions, which is what lets the body offsets be trusted.
-
-The bodies are blocks of fixed-length **sample channels**:
-
-- **v21 (1244 B):** a `(100, 100, 3)` descriptor near `@22`, then **six 100-sample i16 channels** in two
- blocks — **accelerometer at `@28` / `@228` / `@428`** and **gyroscope at `@640` / `@840` / `@1040`**
- (200 B apart; the second block's count sits at `@630` = 100). This is **6-axis IMU, not optical**: on a
- stationary strap the three accel channels sphere-fit to a **~1 g gravity shell** (median |a| = 1.006 g,
- 100/100 samples in-shell on the real fixture) — a gravity vector, which a PPG channel cannot produce.
- (The DC "baselines" ≈1820 / 720 / 3630 a stationary capture shows are exactly that gravity vector:
- √(1820²+720²+3630²)/4096 = 1.007 g.) Validated as 6-axis IMU by `Whoop5RawImu` over 1423 real buffers.
-- **v20 (2140 B):** **five channel blocks**, each preceded by a **presence byte** (`0x19` = active,
- `0x00` = empty/zero-filled). An active block holds **two 50-sample i32 channels**. Presence bytes at
- `@0x1a / 0x1c0 / 0x366 / 0x50c / 0x6b2`; the ten channel slots start at
- `@0x2f / 0xf7 / 0x1d5 / 0x29d / 0x37b / 0x443 / 0x521 / 0x5e9 / 0x6c7 / 0x78f`. i32 LE is the correct
- width (only that alignment yields smooth waveforms; an empty block's 200-byte slots are all-zero across
- every frame, matching its `0x00` presence byte). **v20 sensor identity is OPEN**: there is no labelled
- or moving v20 capture in the tree, and the earlier "same sensor set as v21" claim was the only basis for
- calling it optical — now that v21 is inertial, that inference no longer supports an optical reading. Do
- not treat v20 as an SpO₂/BP optical substrate pending a labelled **moving** capture.
-
- **Independently corroborated (#715).** `whoop-local` lists six 20-bit v20 channel offsets, derived
- separately from this tree: frame `47, 247, 1313, 1513, 1735, 1935`. Those are exactly the six slots of
- our **non-empty** blocks (0, 3 and 4 — the corpus shows block counts are always `[25, 0, 0, 25, 25]`),
- out of the ten listed above. Two unrelated methods agreeing on which six slots carry data, and where,
- is the strongest confirmation this layout has. Note what it does **not** settle: both projects agree on
- the *structure*, and neither has a labelled capture, so the sensor-identity question above is untouched
- — agreement about where the bytes are is not evidence about what produced them.
-
-`decodeWhoop5HistoricalV2021` exposes `layout_marker`, `record_index`, `unix`, and the channels as **raw
-i16 sample arrays with no scale applied at this layer**. For **v21** the channels are named `accel_x/y/z`
-and `gyro_x/y/z` per the gravity-shell evidence above (`Whoop5RawImu.decode` applies the physical scales —
-1/4096 g/LSB accel, 2000/32768 (°/s)/LSB gyro). For **v20** the channels stay neutrally named because its
-sensor identity is still open (needs a labelled/moving capture). Tests: `Whoop5HistoricalV2021Tests.swift`
-(incl. a real-frame gravity-shell assertion) and `Whoop5RawImuTests.swift`.
-
-> The v18 per-second record's own optical region (bytes [57:120]) carries **no simple summary of this
-> PPG** (no field tracks its DC or AC amplitude), and its SpO₂ / skin-temp channels have no internal
-> proxy — HR, R-R, gravity and PPG morphology don't determine blood-oxygen or temperature. Those remain
-> a raw region; positively mapping them needs an external reference (a worn pulse-oximeter / thermometer,
-> or the official app's readout for matching timestamps), so they are intentionally left undecoded.
+The examined captures include **2,140-byte R20 optical records** and **1,244-byte
+R21 six-axis IMU records**, observed as a pair per second in that capture. A previous
+decoder fell back to an unmapped layout and stored no rows (issue #344). Use the
+[canonical R20 layout](PROTOCOL_SENSORS.md#r20-optical-blocks) and
+[R21 layout](PROTOCOL_SENSORS.md#r21-six-axis-imu) for decoding rather than a second
+copy of their offset tables.
+
+Capture observations retained from that investigation:
+
+- Both layouts had a valid trailing payload CRC32, a record index at 11 and time at
+ 15. The observed markers were 81/80 hex. R20 marker bit 0 can also reflect
+ conditional fallback routing, so it is not constant by layout or a validity flag.
+- R21 was validated as six-axis IMU over 1,423 real buffers. A stationary fixture
+ gave median acceleration magnitude 1.006 g, with all 100 samples in the examined
+ gravity shell; raw baselines approximately 1820/720/3630 give 1.007 g at 1/4096.
+ The implementation tests include `Whoop5HistoricalV2021Tests.swift` and
+ `Whoop5RawImuTests.swift`.
+- R20 block counts were `[25,0,0,25,25]` in the examined corpus. **25 is the valid
+ count per slot, not a presence flag**: each populated block contains two slots
+ with 25 valid i32 samples and capacity for 50 each. Empty slots were zero-filled
+ in that capture; they are not measured zero readings.
+- The community offsets 47, 247, 1313, 1513, 1735 and 1935 match the six populated
+ R20 slots. R20 is optical, but wavelengths, detector geometry and physical units
+ remain unresolved. Matching offsets do not resolve those meanings.
+
+These corrections document the wire contract; they do not claim that legacy
+`decodeWhoop5HistoricalV2021` implementations or analysis scripts have been updated.
+Check their count handling and signed widths before consuming their arrays.
> **Firmware-version caveat.** The 4.0 `v24` layout in `whoop_protocol.json` reflects one firmware
> revision (the `my-whoop` reference device); a given strap may run older or newer firmware with a
@@ -672,12 +420,12 @@ real 84-byte records at their absolute offsets and cross-checked physiologically
| 23–72 | optical PPG region | Raw AC-coupled optical ADCs. |
| 73 / 75 / 77 | `gravity_x/y/z` (3× i16 LE) | Accel-derived gravity, scaled `/16384` ≈ 1 g; \|g\| ≈ 1.0 on real records. |
-Note there is **no per-second HR field** in this record: WHOOP 4.0 HR is PPG-derived, not stored — so
-the v25 win is the recovered **timestamp + motion**, which is exactly what the sleep stager (and hence
+No per-second HR field is mapped in this v25 record. WHOOP 4 v24 separately
+contains an HR field; v25's mapped contribution here is **timestamp + motion**, which is exactly what the sleep stager (and hence
recovery) needs. The decoded gravity/motion vector feeds `extractHistoricalStreams` unchanged, the same
path the v24 record uses.
-### WHOOP 4 firmware-drift check — this device showed no drift (hardware-verified)
+### WHOOP 4 firmware-drift check — this device showed no drift
The v18 surprise prompted the obvious question: does a *different* device on *different* firmware still
emit the documented record? Tested on a real WHOOP 4 (firmware **41.17.6.0**) with the tool's WHOOP 4
@@ -748,102 +496,22 @@ as the 4.0 `event` post-hook fail closed.
## 6. Haptic preset discovery (GET_ALL_HAPTICS_PATTERN)
-The strap has a built-in table of haptic waveforms. `GET_ALL_HAPTICS_PATTERN` (command **80**) reports
-the device's preset patterns — **7 presets on the WHOOP 4.0 (Harvard)**, indexed `0–6`. That count is
-a claim about the STRAP's own table, not about NOOP: it would be read with `GET_ALL_HAPTICS_PATTERN`
-(80), and neither platform has ever sent that command, so we have never enumerated it (#926). What the
-app exposes is four `BuzzPattern` choices, all sharing patternId 2. They are fired
-with `RUN_HAPTICS_PATTERN` (command **79**):
+The legacy WHOOP 4 client uses command 79 with preset 2. WHOOP 5/MG uses
+command 19 with a 12-byte pattern body; command 79 is unsupported in the current
+WHOOP 5 command table. See [haptics and alarms](PROTOCOL_ALARMS.md) for the complete
+pattern fields, result handling and busy-state behavior.
-```text
-RUN_HAPTICS_PATTERN payload = [patternId, numLoops, 0, 0, 0] // 5 bytes
-```
+### SET_CLOCK — family-specific payloads
-NOOP uses **`patternId = 2`** — the characteristic graduated "alarm" buzz, observed as the one the
-official app fires, for interoperability (`buzzStrapOnce`, `AppModel.buzz`). `numLoops` sets the
-length; `STOP_HAPTICS` (122) cancels an in-progress pattern. All notification patterns in NOOP map to
-this confirmed preset and vary only the repeat count, so behaviour is predictable on real hardware.
-
-Haptics tie into the firmware **alarm**: `SET_ALARM_TIME` (66) arms a UTC alarm that buzzes even if
-NOOP is closed (event `STRAP_DRIVEN_ALARM_EXECUTED`=57); always `SET_CLOCK` first so the RTC is
-UTC-correct.
-
-### WHOOP 5 / MG haptic — the "maverick" opcode (hardware-verified)
-
-The WHOOP 5 / MG does **not** honour `RUN_HAPTICS_PATTERN`=79 — a real-MG capture showed the strap
-rejecting 79 with `COMMAND_RESPONSE result=0x03`. The 5.0 firmware instead drives haptics with the
-**maverick** opcode **`0x13`** (`RUN_HAPTIC_PATTERN_MAVERICK`=19) — the exact command the official app
-sends, matched byte-for-byte (#48), and shipped in `BLEManager.send()`:
-
-```text
-puffin cmd 0x13, body = [0x01, effects…, loopControl u16 LE, overallLoop]
-NOOP's "notify" preset → effects 47, 152 → body = [01 2F 98 00 00 00 00 00 00 00 00 00] (12 bytes)
-```
-
-The 12-byte body makes the inner record 15 bytes, so the puffin framing **pads it to a 4-byte
-boundary** (`pad4`, → 16) before the declared length and CRC32 — without the pad the strap rejects the
-frame. The strap acknowledges acceptance with `COMMAND_RESPONSE` (type 36) echoing
-`RUN_HAPTIC_PATTERN_MAVERICK(19)`.
-
-**Verified on real hardware (2026-06-12):** a bonded WHOOP 5 buzzed on this exact frame and returned a
-CRC-valid COMMAND_RESPONSE for every send. Frame builders live in `Tools/linux-capture/whoop_frame.py`
-(`build_whoop5_buzz` / `build_whoop4_buzz`, unit-tested against the captured frame) and drive the
-`whoop_buzz.py` find-my-strap tool — see the linux-capture README.
-
-### SET_CLOCK — the payload length is firmware-specific (hardware-verified)
-
-`SET_CLOCK` (command **10**) sets the strap RTC. A strap left offline (no app) for months loses its
-clock: its RTC drifts/resets, and every frame it then emits — realtime *and* newly-written historical
-records — carries a bogus timestamp (we saw a real WHOOP 4 dated **1971**, its RTC counting up from
-~zero). The fix is to send the current unix time, exactly as the app does on each connect (it only
-re-sends when drift exceeds a threshold — `ClockPolicy` — to avoid gratuitous resets).
-
-The payload is `u32 unix-seconds LE` followed by zero subsecond bytes, but **the length is
-firmware-specific and load-bearing** — a wrong length is `COMMAND_RESPONSE`-ack'd but **not latched**:
-
-| Firmware | Body | Result |
-|---|---|---|
-| newer (app's default) | 8-byte `[u32 + 4 zero]` | latches on newer straps; on WHOOP 4 `41.17.6.0` → **no response at all** |
-| older WHOOP 4 `41.17.6.0` | **9-byte** `[u32 + 5 zero]` | **latches** — COMMAND_RESPONSE(cmd 10) + the event clock jumps to the set time |
-
-**Verified on real hardware (2026-06-12, WHOOP 4C fw 41.17.6.0):** the 8-byte form drew no response;
-the 9-byte form latched and the strap's event RTC jumped from 1971 to the correct 2026 time, ticking
-+1/sec. So `build_whoop4_set_clock` sends the 9-byte form; newer firmware may want 8.
-
-Read-back to confirm a latch: the strap's RTC is the u32 LE timestamp in any EVENT or REALTIME frame —
-**but the offset differs by type**: WHOOP 4.0 REALTIME(40) `@6`, EVENT(48) `@8`; WHOOP 5.0 (puffin, +4
-rule) REALTIME(40) `@10`, EVENT(48) `@12` (`frame_rtc` in `whoop_frame.py`). Stored historical records
-keep the timestamp they were written with, so offloading old history does **not** require fixing the
-clock first — only future recordings do. Tooling: `whoop_setclock.py` (read + conditional set) and the
-read-only `whoop_probe.py` — see the linux-capture README.
-
----
+Use the [WHOOP 4 clock profile](PROTOCOL_WHOOP4.md) or
+[WHOOP 5 clock contract](PROTOCOL_TRANSPORT.md#clock-and-identity-contracts).
+A command response alone does not establish a correct RTC value.
## 7. Sensor inventory
-Combining the type-47 DSP record (§5), the type-43 raw streams (§4), and the `EventNumber` enum, the
-WHOOP 4.0 strap exposes the following sensors and actuators. NOOP only consumes what the device already
-measures:
-
-| Sensor / actuator | How it surfaces in the protocol |
-|---|---|
-| **PPG optical** (green + red/IR LEDs, ambient) | type-47 `ppg_green` / `ppg_red_ir` / `ambient` / `led_drive_1/2`; type-43 optical variant (single AC-coupled green waveform @ ~437 Hz). Drives HR, SpO₂, respiratory rate. |
-| **3-axis accelerometer** | type-47 `gravity_x/y/z` (f32, g); type-43 IMU `accelX/Y/Z` (i16 @ ~100 Hz, `1/4096` g/LSB). |
-| **Gyroscope / IMU** | type-43 IMU `gyroX/Y/Z` (i16, `0.06104` deg/s/LSB, ±2000 dps). |
-| **Skin temperature** | type-47 `skin_temp_raw` (u16 ADC); event `TEMPERATURE_LEVEL`. |
-| **Capacitive double-tap** | event `DOUBLE_TAP` (14) → `FrameRouter` fires `onDoubleTap`. |
-| **Wrist detection** | events `WRIST_ON` (9) / `WRIST_OFF` (10); type-47 `skin_contact` (0 = off-wrist). |
-| **Haptic motor** | `RUN_HAPTICS_PATTERN` / `STOP_HAPTICS`; events `HAPTICS_FIRED` (60), `HAPTICS_TERMINATED` (100). |
-| **Battery / charge** | standard `2A19`; type-48 `BATTERY_LEVEL` (SoC/mV/charging ~every 8 min); events `CHARGING_ON/OFF`. |
-
-**Sensors the strap does NOT have** (and that NOOP therefore never fabricates): **no microphone, no
-speaker, no GPS, no display.** All feedback to the wearer is via the single haptic motor; all
-"location" or "audio" context, if any, comes from imported data, never the strap.
-
-The live physical inputs are wired through `FrameRouter.handle(frame:)`: `DOUBLE_TAP` and
-`WRIST_ON`/`WRIST_OFF` events update `LiveState` and can trigger user-configured Mac actions.
-
----
+Use the [sensor record reference](PROTOCOL_SENSORS.md) for optical, motion and
+summary fields, and the [ECG reference](PROTOCOL_ECG.md) for electrical samples.
+A record’s numeric layout does not by itself establish physical units or calibration.
## 8. Extending the decoder
@@ -895,72 +563,24 @@ contradicting bytes actually are before changing the decoder.
### A note on whoop5 offsets
-If you map the WHOOP 5.0 biometric fields, do it in `parseFrameWhoop5` (inner record at offset 8) and
-back it with real 5.0 captures. Until then the 5.0 path intentionally leaves the inner record as an
-unparsed region — describing the frame faithfully without inventing structure.
+Extend WHOOP 5 decoding through the family-specific path and use the
+[canonical record layouts](PROTOCOL_SENSORS.md). Supported layouts already decode
+fields; preserve unsupported layouts and unresolved fields raw. Back changes with
+fixtures of stated provenance, without treating synthetic vectors as device evidence.
### Safety rule
`WhoopCommand` in `Commands.swift` is a **deliberately curated subset**. Destructive or dangerous
commands — firmware load, force-trim, ship-mode, power-cycle, fuel-gauge reset, BLE DFU — are
-**excluded by design** so the in-app command sender can never brick or wipe a device. The one guarded
-exception is `rebootStrap` (a plain, non-destructive restart that keeps stored data), sent only from a
+**excluded by design** from the ordinary command sender. This allowlist is not a guarantee against data loss. The one guarded
+exception is `rebootStrap` (a restart request; complete persistence across restart is
+not established by this command contract), sent only from a
user-initiated, confirmation-gated action — never automatically (#166). When extending the command
set, keep it reversible and non-destructive.
---
-## Appendix: evaluated and rejected — the LINK_VALID handshake (#715)
-
-Recorded so it is not re-proposed. `whoop-local` ([a9eelsh](https://github.com/a9eelsh/whoop-local))
-implements a handshake in which the strap sends a `LINK_VALID` (command 1) and the client must answer
-with a `COMMAND_RESPONSE` (36) carrying `[originSeq, SUCCESS, "There it is."]`, stating that otherwise
-"the strap treats the link as invalid and withholds data."
-
-**As stated, that is contradicted by what NOOP does in the field.** Neither platform has ever had a
-`LINK_VALID` handler — not one reference in `Strand/BLE/` or `com.noop.ble` — and NOOP nonetheless
-completes historical offloads, live HR and sleep sync on WHOOP 5/MG for real users. If the strap
-withheld data from a client that never answers, *every* 5/MG user would get nothing; instead the
-5/MG reports we receive are about intermittent disconnects (#802) and state restoration (#613), not
-about a strap that never sends anything.
-
-The decode corpora point the same way — **~258k v18 records**, an **18,602-record** v18 span from a
-third strap's overnight stream, a **29,203-record** v20 corpus — though note those are cited here as
-corroboration, not proof: this project also has HCI-snoop tooling (`hci_extract.py`, #103), and a
-corpus extracted from a snoop of the *official* app would have been produced by a client that DID
-answer. The field-behaviour argument above does not depend on how any corpus was captured.
-
-What may still be true is narrower: their handshake sits alongside `TOGGLE_IMU_MODE` (106) and
-`TOGGLE_OPTICAL_MODE` (108), which arm the realtime R20/R21 raw streams — so if the exchange gates
-anything, it plausibly gates *those streams* rather than the offload.
-
-Our exposure to that is limited but not zero, and worth stating precisely. NOOP takes live HR from the
-standard `0x2A37` profile and disables the R10/R11 flood on connect (§4), and it never sends
-`TOGGLE_OPTICAL_MODE` (108) at all. But `captureRawAccel` **does** send `START_RAW_DATA` (81) +
-`TOGGLE_IMU_MODE` (106) — on demand, for a bounded window, never continuously. That path is now
-hardware-verified to yield decoded 100 Hz six-axis IMU after the two-byte 5/MG selector is used.
-It works without implementing a `LINK_VALID` response, so the proposed handshake is not required for
-this raw-IMU producer on the tested strap/firmware. This does not prove that every firmware treats
-`LINK_VALID` identically or that ignoring it can never affect link stability.
-
-**Two questions are genuinely open**, and a capture answers both without implementing anything:
-
-1. Does a WHOOP 5/MG ever send us a type-35 `COMMAND` with `cmd == 1`? The schema knows both
- (`PacketType 35 = COMMAND`, `CommandNumber 1 = LINK_VALID`) but nothing routes it, so today it would
- arrive and be dropped silently.
-2. If it does, does ignoring it affect *link stability* — as distinct from data flow? That is a
- different claim from the one above and is not disproved by the corpus. It would be a candidate
- contributor to the intermittent-disconnect reports (#802, #613), though those look unlike a link the
- strap has declared invalid.
-
-**Provenance caveat, and it is the deciding one.** The whole path is decompile-sourced
-(`com/whoop/service/rearchitect/c.java`, `zi0/*`, `bj0/x.java`, `bj0/f.java`) and its payload is a
-**literal string lifted from the app**. This project reimplements decompile-sourced *facts* with
-attribution when they are treated as unvalidated candidates — `spo2_candidate_82` is the precedent —
-but an offset is a fact and a magic string is expression. Even if question 1 turns out yes, the reply
-should be derived from our own capture of what the strap accepts, not copied.
-
## Appendix: observed but undecoded (#791)
A reporter running an instrumented build on a **WHOOP 4.0 with recent firmware** (Galaxy S24 Ultra) dumped
@@ -1020,30 +640,11 @@ consistently across the whole session:
| `0x1a` GET_BATTERY_LEVEL | 0 | silent |
| `0x23` GET_HELLO_HARVARD | 0 | silent |
-Consequences worth carrying forward:
-
-- **Battery is unreachable on this firmware.** Command 26 silent, command 98 answering `FAILURE`, no
- `BATTERY_LEVEL(3)` events in 40 minutes, no `EXTENDED_BATTERY_INFORMATION(63)` events either, and the
- standard `0x2A19` characteristic a constant-100 stub. All four sources are dead, so there is no
- client-side path to the percent — not a decode bug to find.
-- **`0x62` answers while `0x1a` does not**, which is odd enough to be a lead: the extended-battery opcode is
- reachable but fails, so it may want a sub-command or page selector in its request payload rather than the
- `[0x00]` the probe sends.
-- The strap also had stretches — one 25-minute bonded session — where it answered *nothing* despite working
- realtime and backfill streams, then became chatty later. State-dependent, cause unknown.
---
## Summary
-NOOP interoperates with a WHOOP strap you own by: scanning for its hidden custom GATT service,
-triggering just-works bonding with a single confirmed `GET_BATTERY_LEVEL` write, reassembling the
-`0xAA` CRC-framed messages, and decoding them with a data-driven schema. The expensive type-43 raw
-flood is switched off on connect (`SEND_R10_R11_REALTIME [0x00]`), leaving the periodically-offloaded
-type-47 14-day biometric store as the primary on-device data source. WHOOP 4.0 and 5.0 differ only in
-their GATT UUIDs, header checksum (CRC8 vs CRC16-Modbus), inner-record offset, and session start — all
-funnelled through `DeviceFamily`. The work stands on the shoulders of `johnmiddleton12/my-whoop`
-(4.0) and `b-nnett/goose` (5.0), with sensor scales and offsets re-verified on real hardware.
> Reminder: not affiliated with WHOOP; not a medical device. All values are raw or locally-estimated
> and are for personal, informational use only.
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 3227d6fc5d..cbdc3d2666 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -1,980 +1,106 @@
-# WHOOP BLE Protocol
-
-This document specifies the Bluetooth Low Energy (BLE) wire protocol that NOOP uses to talk
-**directly to a WHOOP strap you own** (4.0 and 5.0/MG). It is a reverse-engineering reference:
-frame envelope, checksums, packet/command/event enumerations, the bond handshake, and the
-historical-data offload state machine.
-
-NOOP is a standalone, fully offline companion. It pairs over BLE, decodes the strap's own
-streams on-device, and stores everything locally in SQLite. There is no cloud or account
-involved in any of the exchanges described here.
-
-> **Interoperability & safety note.** This describes interoperation with the user's *own*
-> device and the data it already holds. NOOP is **not affiliated with, authorized by, or
-> endorsed by WHOOP**, and it is **not a medical device** — nothing here is intended for
-> diagnosis or treatment. The command set NOOP sends is deliberately a *safe subset*;
-> destructive opcodes are documented only so they can be explicitly avoided
-> (see [Destructive commands — do not send](#destructive-commands--do-not-send)).
-
-The protocol decoder is platform-pure Swift in the `WhoopProtocol` package
-(`Packages/WhoopProtocol/`); it never imports CoreBluetooth, so it runs unchanged in tests and
-CLI tools. The CoreBluetooth transport lives under `Strand/BLE/` and is shared by both the
-macOS and iOS app targets (the platform-pure `WhoopProtocol` package above stays
-CoreBluetooth-free for tests and CLI tools).
-
-This work builds on two community reverse-engineering efforts:
-
-- **`johnmiddleton12/my-whoop`** — WHOOP 4.0 protocol.
-- **`b-nnett/goose`** — WHOOP 5.0 fd4b ("puffin" packet framing) protocol.
-
-The canonical decode tables are bundled as a JSON resource:
-`Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json`, loaded by
-`loadSchema()` in `Schema.swift`.
-
----
-
-## 1. GATT topology
-
-Each WHOOP generation advertises a vendor-specific primary service plus the two standard SIG
-services (Heart Rate and Battery). The custom service carries the framed command/response/
-event/data channels; the standard services work even before bonding.
-
-### WHOOP 4.0 — service `61080001-…`
-
-Defined in `BLEManager.swift` (the on-device, authoritative UUIDs) and mirrored as plain
-strings in `DeviceFamily.swift`. The same `Strand/BLE/` sources (`BLEManager`,
-`StandardHeartRate`, `FrameRouter`) back both Apple-platform targets — macOS and iOS.
-
-| Role | UUID | Direction |
-|------|------|-----------|
-| Custom service | `61080001-8d6d-82b8-614a-1c8cb0f8dcc6` | — |
-| Command write (`cmdWriteChar`) | `61080002-8d6d-82b8-614a-1c8cb0f8dcc6` | app → strap |
-| Command-response notify (`cmdNotifyChar`) | `61080003-8d6d-82b8-614a-1c8cb0f8dcc6` | strap → app |
-| Event notify (`eventNotifyChar`) | `61080004-8d6d-82b8-614a-1c8cb0f8dcc6` | strap → app |
-| Data notify (`dataNotifyChar`, fragmented) | `61080005-8d6d-82b8-614a-1c8cb0f8dcc6` | strap → app |
-
-### WHOOP 5.0 / MG — service `fd4b0001-…`
-
-The 5.0 transport ("puffin") adds a fifth characteristic (`…0007`). UUID strings are in
-`DeviceFamily.characteristicUUIDStrings`.
-
-| Role | UUID |
-|------|------|
-| Custom service | `fd4b0001-cce1-4033-93ce-002d5875f58a` |
-| Command write | `fd4b0002-cce1-4033-93ce-002d5875f58a` |
-| Notify channels | `fd4b0003`, `fd4b0004`, `fd4b0005`, `fd4b0007` (`…-cce1-4033-93ce-002d5875f58a`) |
-
-NOOP's historical "puffin" label refers to this fd4b Maverick/Goose framing. Decompiled WHOOP app
-taxonomy also names a separate `PUFFIN` service family at
-`11500001-6215-11ee-8c99-0242ac120002`; NOOP names that metadata `puffin1150` to avoid confusing it
-with the implemented fd4b path.
-
-### Diagnostic-only WHOOP service families
-
-The official app also models additional WHOOP service families with the same `0001` service plus
-`0002`/`0003`/`0004`/`0005`/`0007` characteristic pattern. NOOP lists these as protocol metadata and
-logs them when advertised, but does not connect, discover characteristics, or send commands for them
-until the correct framing is mapped and hardware-tested.
-
-| Family label in NOOP | Service UUID | Current status |
-|----------------------|--------------|----------------|
-| `puffin1150` | `11500001-6215-11ee-8c99-0242ac120002` | detected but unsupported |
-| `monument` | `8a580001-2fe8-4796-9267-b87a2b0c8234` | detected but unsupported; likely Castle/Rev2 framing |
-| `symphony` | `59830001-5955-419b-bb8d-c8262926af23` | detected but unsupported; likely Castle/Rev2 framing |
-
-### Standard SIG services (both generations)
-
-| Service | UUID | Characteristic | UUID | Notes |
-|---------|------|----------------|------|-------|
-| Heart Rate | `180D` | HR Measurement | `2A37` | HR + R-R; works **unbonded** |
-| Battery | `180F` | Battery Level | `2A19` | single byte = battery percent |
-
-The `0x2A37` channel is the BLE-standard Heart Rate Measurement and is parsed by the pure
-`StandardHeartRate.parse(_:)` (`Strand/BLE/StandardHeartRate.swift`): flag byte, 8- or 16-bit
-HR, optional Energy-Expended skip, then R-R intervals in 1/1024 s converted to milliseconds.
-NOOP treats this as the *reliable* HR/R-R source (the custom `REALTIME_DATA` stream usually
-reports `rr_count = 0`). `0x2A19` is read as a raw percent (`state.setBattery(Double(pct))`).
-
-`DeviceFamily` keeps CoreBluetooth out of the protocol package: it exposes UUIDs as **strings**;
-the app layer wraps them in `CBUUID(string:)`.
-
----
-
-## 2. Frame envelope
-
-A frame is a self-delimiting byte string beginning with a Start-Of-Frame marker and ending with
-a CRC32 trailer. The two generations share the CRC32 payload check but differ in the header
-checksum. The branch point is `DeviceFamily.headerCRCKind`:
-
-| Family | Header check | Enum (`HeaderCRCKind`) |
-|--------|--------------|------------------------|
-| `whoop4` | CRC8 (poly `0x07`) | `.crc8` |
-| `whoop5` | CRC16-Modbus (poly `0xA001`, init `0xFFFF`, reflected) | `.crc16Modbus` |
-
-### 2.1 WHOOP 4.0 envelope
-
-```
-┌──────┬───────────────┬───────┬───────────── inner ─────────────┬─────────────┐
-│ 0xAA │ length u16 LE │ crc8 │ type │ seq │ cmd │ payload … │ crc32 u32 LE│
-│ [0] │ [1..3] │ [3] │ [4] │ [5] │ [6] │ [7 .. len) │ [len .. +4) │
-└──────┴───────────────┴───────┴───────────────────────────────────┴────────────┘
-total frame size = length + 4
-```
-
-- **`0xAA`** — Start Of Frame.
-- **`length`** — `u16` little-endian. Equals `inner.count + 4` (the inner `[type][seq][cmd]
- payload]` plus the 4 envelope bytes). It is the offset at which the CRC32 trailer begins.
-- **`crc8`** — CRC8 (table-driven, poly `0x07`) computed over the **two length bytes only**
- (`crc8([frame[1], frame[2]])`).
-- **inner record** — `type` (packet type, §3), `seq` (sequence / version byte), `cmd`
- (command number, §6), then the payload.
-- **`crc32`** — standard zlib CRC-32 (reflected, poly `0xEDB88320`), `u32` little-endian,
- computed over the **inner bytes** `frame[4 .. length)`.
-
-Reference: `verifyFrame(_:)` and `crc8(_:)` / `crc32(_:)` in `Framing.swift`, and the
-outbound builder `WhoopCommand.frame(seq:payload:)` in `Strand/BLE/Commands.swift`.
-
-```swift
-// Framing.swift — WHOOP 4.0 validation (abridged)
-let length = u16le(frame, 1)
-let crc8OK = crc8([frame[1], frame[2]]) == frame[3]
-if 7 <= length && length + 4 <= frame.count {
- let inner = Array(frame[4.. FrameCheck
-public func parseFrame(_ frame: [UInt8], family: DeviceFamily) -> ParsedFrame
-```
-
-`whoop4` behaves exactly like the no-family overloads (back-compat). The "puffin" types
-`38 PUFFIN_COMMAND_RESPONSE` and `56 PUFFIN_METADATA` are aliased onto `COMMAND_RESPONSE` /
-`METADATA` by `canonicalTypeName(_:schema:)` so they never decode as "unknown".
-
-### 2.4 COMMAND_RESPONSE body
-
-Every reply to a command (`COMMAND_RESPONSE`, type 36 — and its 5/MG alias 38) opens with two bytes
-before whatever the command itself returns:
-
-```
-WHOOP 4.0 [6] resp_cmd [7] resp_seq [8] result [9..] per-command body
-WHOOP 5/MG [10] resp_cmd [11] resp_seq [12] result [13..] per-command body
-```
-
-the 5/MG offsets being the 4.0 ones + 4, like the rest of the puffin inner record.
-
-- **`resp_cmd`** — the command being answered (`CommandNumber`).
-- **`resp_seq`** — the strap's own per-response counter. Not the envelope `seq` at `[5]`/`[9]`, which is
- host-assigned and echoed back: a single capture shows envelope `seq` 147 alongside `resp_seq` 2. A
- repeated `resp_seq` across replies is how a duplicated write was identified in #791.
-- **`result`** — `CommandResult`: `0` FAILURE, `1` SUCCESS, `2` PENDING, `3` UNSUPPORTED. `GET_DATA_RANGE`
- answers PENDING then SUCCESS; `3` is what a real MG returned when it rejected `RUN_HAPTICS_PATTERN`
- (#48). Both fields are decoded from the bounded payload slice, so a reply too short to carry them
- yields neither rather than reading the CRC32 trailer (#894).
-
-**The first body byte is per-command, and is not a status flag.** `GET_BATTERY_LEVEL` puts the charge
-percentage there — `47` in the hardware-confirmed fixture — so the slot carries real data. On other
-commands it has only ever been observed as `1`:
-
-| capture | command | result | first body byte |
-|---|---|---|---:|
-| real 5/MG | `GET_BATTERY_LEVEL` | SUCCESS | **47** (= 47%) |
-| real 5/MG | `GET_DATA_RANGE` | SUCCESS | 1 |
-| real MG | `SELECT_WRIST`, accepted | SUCCESS | 1 |
-| real MG | `SELECT_WRIST`, refused | FAILURE | 1 |
-| real MG | `TOGGLE_LABRADOR_*` | SUCCESS | 1 |
-
-For the wrist and ECG commands what that `1` means is **open**. A capture that sent `SELECT_WRIST` with
-argument `0` got `1` back, which refutes an echo of the request — but every frame anyone has captured had
-a stored value of `1`, so "reads back stored state" and "this handler writes a literal `1`" make identical
-predictions on all of them. It is therefore left undecoded rather than named; settling it needs a reply
-from a strap whose stored value is `0`. See #891.
-
-### 2.5 Checksums
-
-| Algorithm | Function | Parameters |
-|-----------|----------|------------|
-| CRC8 | `crc8(_:)` | table-driven, poly `0x07`, init `0x00` |
-| CRC32 (zlib) | `crc32(_:)` | reflected, poly `0xEDB88320`, init `0xFFFFFFFF`, final XOR `0xFFFFFFFF` |
-| CRC16-Modbus | `crc16Modbus(_:)` | poly `0xA001`, init `0xFFFF`, reflected |
-
-CRC32 is the protocol's **only payload-integrity guarantee**. Decode and state-update paths
-reject any frame whose CRC32 fails: `FrameRouter.handle(frame:)` bails on `parsed.crcOK == false`,
-and `classifyHistoricalMeta(_:)` refuses to act on a frame where `p.crcOK == false` — without
-that gate a garbled or hostile peer could forge a `HISTORY_END`/`HISTORY_COMPLETE` and advance
-the strap's trim cursor, discarding data that was never durably stored.
-
-### 2.6 Reassembly
-
-BLE notifications arrive as MTU-sized fragments. `Reassembler` (`Framing.swift`) accumulates
-bytes, finds the `0xAA` SOF, reads the `u16` LE length at `buf[1..3]`, and emits a complete
-frame once `buf.count ≥ length + 4`. Leading garbage before an SOF is discarded; a buffer with
-no SOF is dropped. The app feeds the data/cmd/event notify characteristics through one
-`Reassembler` in `peripheral(_:didUpdateValueFor:error:)`.
-
-```swift
-// usage in BLEManager
-for frame in reassembler.feed(bytes) {
- router.handle(frame: frame) // UI/state
- // … live ingest or backfill routing …
-}
-```
-
-`frameFromPayload(_:type:seq:cmd:)` reconstructs a complete frame from a bare payload (used when
-a capture stored only the data portion): it rebuilds the envelope with a correct zlib CRC32 and
-a placeholder `0x00` CRC8 byte.
-
----
-
-## 3. PacketType (offset `[4]`, or `[8]` on 5.0)
-
-Source: `enums.PacketType` in `whoop_protocol.json`; resolved by `Schema.typeName(_:)`.
-
-| Value | Name | Notes |
-|------:|------|-------|
-| 35 | `COMMAND` | outbound command (app → strap) |
-| 36 | `COMMAND_RESPONSE` | reply to a command |
-| 37 | `PUFFIN_COMMAND` | WHOOP 5.0 command |
-| 38 | `PUFFIN_COMMAND_RESPONSE` | WHOOP 5.0; aliased → `COMMAND_RESPONSE` |
-| 40 | `REALTIME_DATA` | live HR / R-R |
-| 43 | `REALTIME_RAW_DATA` | live IMU/optical flood (~2/s, ~1.9 KB) |
-| 47 | `HISTORICAL_DATA` | offloaded biometric records |
-| 48 | `EVENT` | strap event (§4) |
-| 49 | `METADATA` | offload control metadata (§7) |
-| 50 | `CONSOLE_LOGS` | firmware log text |
-| 51 | `REALTIME_IMU_DATA_STREAM` | |
-| 52 | `HISTORICAL_IMU_DATA_STREAM` | |
-| 53 | `RELATIVE_PUFFIN_EVENTS` | WHOOP 5.0 |
-| 54 | `PUFFIN_EVENTS_FROM_STRAP` | WHOOP 5.0 |
-| 55 | `RELATIVE_BATTERY_PACK_CONSOLE_LOGS` | |
-| 56 | `PUFFIN_METADATA` | WHOOP 5.0; aliased → `METADATA` |
-
-`isOffloadFrame(_:)` (in `BLEManager`) treats **47/48/49/50** as offload traffic; the live
-`REALTIME_DATA`(40)/`REALTIME_RAW_DATA`(43) flood is excluded so it cannot keep the backfill
-idle-watchdog alive.
-
-The parser also exposes irregular fields through per-type **post-hooks**
-(`registerPostHooks()` in `PostHooks.swift`): `realtime_data`, `event`, `command_response`,
-`raw_data`, `historical_data`, `metadata`, `console_logs`. The static field layout per packet
-comes from the schema's `packets` table; `REALTIME_RAW_DATA` is keyed by payload length
-(`"1917"` = IMU, `"1921"` = optical), and `HISTORICAL_DATA` by its version byte (`seq`).
-
----
-
-## 4. EventNumber (`EVENT`, type 48, value at `[6]`)
-
-`EVENT` frames carry an `EventNumber` at `[6]` and a `u32` `event_timestamp` at `[8]`. A
-strap-pushed event is WHOOP's "strap-as-clock" signal: NOOP treats any event as "I may have new
-data" and kicks a rate-limited sync (`FrameRouter.onSyncTrigger` → `requestSync(.strap)`).
-Selected, frequently-used values (full table in `whoop_protocol.json`):
-
-| Value | Name | | Value | Name |
-|------:|------|-|------:|------|
-| 3 | `BATTERY_LEVEL` | | 42 | `ACCELEROMETER_SATURATION_DETECTED` |
-| 7 | `CHARGING_ON` | | 46 | `RAW_DATA_COLLECTION_ON` |
-| 8 | `CHARGING_OFF` | | 47 | `RAW_DATA_COLLECTION_OFF` |
-| 9 | `WRIST_ON` | | 56 | `STRAP_DRIVEN_ALARM_SET` |
-| 10 | `WRIST_OFF` | | 57 | `STRAP_DRIVEN_ALARM_EXECUTED` |
-| 13 | `RTC_LOST` | | 58 | `APP_DRIVEN_ALARM_EXECUTED` |
-| 14 | `DOUBLE_TAP` | | 59 | `STRAP_DRIVEN_ALARM_DISABLED` |
-| 17 | `TEMPERATURE_LEVEL` | | 60 | `HAPTICS_FIRED` |
-| 23 | `BLE_BONDED` | | 63 | `EXTENDED_BATTERY_INFORMATION` |
-| 32 | `CAPTOUCH_AUTOTHRESHOLD_ACTION` | | 96 | `HIGH_FREQ_SYNC_PROMPT` |
-| 33 | `BLE_REALTIME_HR_ON` | | 97 | `HIGH_FREQ_SYNC_ENABLED` |
-| 34 | `BLE_REALTIME_HR_OFF` | | 98 | `HIGH_FREQ_SYNC_DISABLED` |
-| 40 | `CH1_SATURATION_DETECTED` | | 100 | `HAPTICS_TERMINATED` |
-| 41 | `CH2_SATURATION_DETECTED` | | | |
-
-`FrameRouter` maps several physical events to UI callbacks: `BLE_BONDED` confirms bonding,
-`DOUBLE_TAP` fires `onDoubleTap`, `WRIST_ON`/`WRIST_OFF` toggle `worn` and fire `onWristChange`.
-The `BATTERY_LEVEL` event has a fixed decoded layout (see the `event` post-hook):
-`soc% = u16@17 / 10`, `mV = u16@21`, `charging = u8@26 & 1`.
-
----
-
-## 5. Bond handshake & connect lifecycle (WHOOP 4.0)
-
-The custom channels only flow once the link is bonded. CoreBluetooth performs *just-works*
-bonding the moment a confirmed (`.withResponse`) write succeeds, so NOOP bonds by sending one
-benign command and waiting for the write acknowledgement.
-
-```
-scan(service 61080001) ─▶ connect ─▶ discoverServices
- └▶ discoverCharacteristics
- ├ on cmdWriteChar (0002):
- │ confirmed write GET_BATTERY_LEVEL ── THE BOND TRICK
- └ on 0003/0004/0005/2A37/2A19: setNotifyValue(true)
- confirmed-write ack (didWriteValueFor, no error) ─▶ BONDED (state.bonded = true)
-```
-
-After bonding, the connect handshake runs **exactly once** per connection (guarded by
-`connectHandshakeDone`, because `didWriteValueFor` re-fires on every later `.withResponse`
-write). Re-blasting the handshake mid-offload was the historical root cause of the strap
-refusing to stream type-47, so the guard is load-bearing. The one-shot handshake (in
-`peripheral(_:didWriteValueFor:error:)`) issues, in order:
-
-1. `GET_HELLO_HARVARD` (35) — version/identity hello (mirrors the official flow; not strictly
- required to serve).
-2. `GET_ADVERTISING_NAME_HARVARD` (76).
-3. `SET_CLOCK` (10) — set the strap RTC to UTC; payload is the **8-byte** form
- `[seconds u32 LE][subseconds u32 LE]` (`BLEManager.setClockPayload()`). A wrong-length
- `SET_CLOCK` is ack'd but not latched, leaving the RTC "lost" so the strap won't serve type-47.
-4. `GET_CLOCK` (11) with an **empty** payload (the strap ignores a wrong-length payload). The
- response establishes the device↔wall `ClockRef` correlation used for realtime decode.
-5. `SEND_R10_R11_REALTIME` (63) with `[0x00]` — stop the ~2/s type-43 raw flood (BLE airtime /
- battery / flash). This is the *real* control for that stream; `STOP_RAW_DATA` (82) does not
- affect it.
-6. `GET_DATA_RANGE` (34) — refresh the strap's stored record range for the liveness watchdog.
-7. After ~1.5 s (so the link settles), the first historical offload via `requestSync(.connect)`.
-
-A periodic backfill timer (`backfillIntervalSeconds = 900`, i.e. 15 min, matching WHOOP) and a
-keep-alive timer (`keepAliveIntervalSeconds = 30`: re-arm realtime, poll battery, watchdog the
-link) are then started. The `GET_CLOCK` response is decoded by `ClockCorrelation` to produce a
-`ClockRef(device:wall:)`; this unblocks both the live `Collector` and the `Backfiller`.
-
-> WHOOP 5.0 instead writes the static `CLIENT_HELLO` frame (§2.2) to its `…0002` command
-> characteristic immediately after discovery.
-
----
-
-## 6. CommandNumber (sending) — the safe subset
-
-NOOP exposes a curated, **safe** command set in `WhoopCommand` (`Strand/BLE/Commands.swift`).
-The raw value is the on-wire command byte at `[6]` (inside a type-35 `COMMAND` frame). Commands
-are built by `WhoopCommand.frame(seq:payload:)` and written to `…0002`.
-
-```swift
-public func frame(seq: UInt8, payload: [UInt8] = [0x00]) -> [UInt8] {
- let inner: [UInt8] = [35 /* COMMAND */, seq, rawValue] + payload
- let length = UInt16(inner.count + 4)
- let lenBytes: [UInt8] = [UInt8(length & 0xFF), UInt8(length >> 8)]
- return [0xAA] + lenBytes + [crc8(lenBytes)] + inner + crc32(inner) /* LE */
-}
-```
-
-| Code | Command | Typical payload | Purpose |
-|-----:|---------|-----------------|---------|
-| 1 | `LINK_VALID` | — | link keep-alive |
-| 3 | `TOGGLE_REALTIME_HR` | `[0x01]`/`[0x00]` | start/stop live HR stream (type-40) |
-| 7 | `REPORT_VERSION_INFO` | — | firmware versions (decoded by `command_response` hook) |
-| 10 | `SET_CLOCK` | `[secs u32 LE][subsecs u32 LE]` | set strap RTC (UTC) |
-| 11 | `GET_CLOCK` | *empty* | read RTC → `ClockRef` correlation |
-| 22 | `SEND_HISTORICAL_DATA` | `[0x00]` | begin offload of the type-47 store |
-| 23 | `HISTORICAL_DATA_RESULT` | `[0x01] + end_data(8)` | ack a `HISTORY_END` chunk / advance trim |
-| 26 | `GET_BATTERY_LEVEL` | `[0x00]` | battery percent; also the **bond** write |
-| 34 | `GET_DATA_RANGE` | `[0x00]` | strap's stored oldest/newest record range; #689 also logs a diagnostic ring-buffer page backlog — see below |
-| 35 | `GET_HELLO_HARVARD` | `[0x00]` | identity/version hello; the response carries the 4.0 strap serial — see below |
-| 39 / 40 | `SET_LED_DRIVE` / `GET_LED_DRIVE` | — | optical LED drive (research) |
-| 41 / 42 | `SET_TIA_GAIN` / `GET_TIA_GAIN` | — | optical front-end gain (research) |
-| 43 / 44 | `SET_BIAS_OFFSET` / `GET_BIAS_OFFSET` | — | optical bias (research) |
-| 63 | `SEND_R10_R11_REALTIME` | `[0x00]` off / `[0x01]` on | the **real** type-43 raw-stream switch |
-| 66 | `SET_ALARM_TIME` | `[0x01]+epoch u32 LE+[0,0]` | arm firmware alarm |
-| 67 | `GET_ALARM_TIME` | `[0x01]` | read armed alarm |
-| 68 | `RUN_ALARM` | `[0x01]` | app-driven alarm now |
-| 69 | `DISABLE_ALARM` | `[0x01]` | disarm firmware alarm |
-| 76 | `GET_ADVERTISING_NAME_HARVARD` | `[0x00]` | advertised name |
-| 79 | `RUN_HAPTICS_PATTERN` | `[patternId, loops, 0,0,0]` | buzz a preset haptic pattern |
-| 80 | `GET_ALL_HAPTICS_PATTERN` | — | enumerate preset patterns |
-| 81 / 82 | `START_RAW_DATA` / `STOP_RAW_DATA` | `[0x01]` | raw-data collection toggle |
-| 84 | `GET_BODY_LOCATION_AND_STATUS` | — | wrist/body-location status (read-only diagnostic probe, #690 — below) |
-| 96 / 97 | `ENTER_HIGH_FREQ_SYNC` / `EXIT_HIGH_FREQ_SYNC` | `[0x00]` | high-freq offload mode |
-| 98 | `GET_EXTENDED_BATTERY_INFO` | — | extended battery (mV etc.) |
-| 100 | `CALIBRATE_CAPSENSE` | — | recalibrate cap-touch |
-| 105 / 106 | `TOGGLE_IMU_MODE_HISTORICAL` / `TOGGLE_IMU_MODE` | `[0x01]` | IMU stream mode |
-| 107 | `ENABLE_OPTICAL_DATA` | — | optical (PPG) data |
-| 117 | `START_FF_KEY_EXCHANGE` | `[0x01]` | how many feature flags the firmware knows (read-only enumeration probe, #761 — below) |
-| 118 | `SEND_NEXT_FF` | `[0x01]` | next feature-flag NAME (cursor, not index; read-only, #761 — below) |
-| 122 | `STOP_HAPTICS` | `[0x00]` | stop an in-progress haptic |
-| 123 | `SELECT_WRIST` | — | set strap wrist |
-
-**5/MG raw-IMU sequence (hardware-verified):** command 106 accepting a write does not mean that the
-producer started. A bounded capture first sends `START_RAW_DATA` (81) `[0x01]`, then command 106 with
-the two-byte selector `[0x01, 0x01]`. Stop uses `STOP_RAW_DATA` (82) `[0x01]`, then command 106
-`[0x01, 0x00]`. The one-byte payload in the table remains the WHOOP 4 form. See
-[5/MG raw data capture](RAW_DATA_CAPTURE.md) for storage, history repair, and export semantics.
-
-**Payload builders** in `WhoopCommand`:
-
-- `setAlarmPayload(epochSec:)` → `[0x01] + epoch u32 LE + [0x00, 0x00]` (7 bytes).
-- `BLEManager.setClockPayload(now:)` → `[secs u32 LE][0,0,0,0]` (8 bytes; subseconds in
- 1/32768 s, zero is fine).
-
-> **Note on `ENTER_HIGH_FREQ_SYNC` (96):** current builds do **not** enter high-freq sync; they
-> send `EXIT_HIGH_FREQ_SYNC` (97) defensively on connect to release a strap a previous app may
-> have parked there. Plain `SEND_HISTORICAL_DATA` returns the type-47 store without it.
-
-### Additional 5-class command numbers
-
-Command bytes present on a 5-class (MAVERICK) strap beyond the safe subset above. NOOP does not
-send these; they are recorded for completeness.
-
-| Code | Command | Purpose |
-|-----:|---------|---------|
-| 48 (0x30) | `SEND_EVENT_PACKETS` | flush stored event packets |
-| 61 (0x3D) | `SET_AFE_PARAMETERS` | set optical AFE parameters |
-| 62 (0x3E) | `GET_AFE_PARAMETERS` | read optical AFE parameters |
-
-On MAVERICK the clock commands also answer in the high opcode space — `SET_CLOCK` at 146 (0x92)
-and `GET_CLOCK` at 147 (0x93), alongside `GET_HELLO` at 145 (0x91) — distinct from the 4.0
-numbers (10 / 11) above.
-
-The strap further exposes an ECG/HeartKey command family. The `CommandNumber` table carries four codes
-for it — 123 `SELECT_WRIST`, 124 `TOGGLE_LABRADOR_DATA_GENERATION`, 125 `TOGGLE_LABRADOR_RAW_SAVE`,
-139 `TOGGLE_LABRADOR_FILTERED` — which are **not** contiguous, and an earlier revision of this section
-described "five consecutive codes around 0x7B–0x8B" against five names (`ECG_MAIN_CONTROL`,
-`ECG_SEND_RAW`, `ECG_SAVE_RAW`, `ECG_SAVE_FILTERED`, `ECG_SELECT_WRIST`). Both cannot be right:
-`ECG_SEND_RAW` has no code, and 139 (0x8B) is separated from 123–125 (0x7B–0x7D). Treat the name↔code
-mapping as unconfirmed — the 5/MG is known to remap opcodes into the high space (the clock family answers
-at 145/146/147 there versus 10/11 on a 4.0), so a code that is accepted is not evidence that it means
-what the name says.
-
-The turn-on ORDER and the 124 argument are attested on one device. On a WHOOP MG (`WS50_r00`, fw
-`50.39.1.0`), 139 gates the **stream**: with it off nothing arrives, so the working sequence is
-**`139 = 1` then `124 = 2`**, after which type-43 carries a ~100 Hz single-channel i16 waveform,
-present only while both clasp electrodes are held. 139 does not appear to gate the front end itself —
-with 139 closed, `124 = 2` still made the strap's own `CONSOLE_LOGS` report `MAX86176: Set ECG ON`
-while no packets arrived (eight sends, eight console lines, correlated on the strap's own uptime;
-#891). Both directions are reversible (`124 = 1` or `139 = 0` stop the stream, both `SUCCESS`);
-disconnecting also clears it. One device, one firmware — see the ⚠️ on `ControlSignal`.
-
-What is confirmed on the other device: on a real WHOOP 5 MG (`WS50_r03`), 124, 125 and 139 are all
-**accepted** — each answers `COMMAND_RESPONSE` with result `SUCCESS(1)` — and no ECG-shaped data
-followed in a 30-second window. Those runs used `124 = 1` as their start verb, which under the mapping
-above stops generation. That is a null result with several live explanations (an open electrode circuit, flash rather
-than a realtime channel, a wrong opcode mapping, no start verb, a flag block, an entitlement gate); see
-#891. The three reply frames are pinned as decode fixtures in `Whoop5CommandResponseTests` /
-`CommandCatalogueTest`.
-
-NOOP sends these only from the gated, hand-run MG ECG probe described in
-[§9.1](#91-ecg-labrador-on-the-mg) — never automatically, never on a plain 5.0 or a 4.0, and only
-behind the Experimental opt-in plus a positively-identified MG. That is four codes for five names, so
-the correspondence remains a working hypothesis rather than a confirmed mapping.
-
-The strap also exposes an `IMU_SET_DATA_STREAM` (code 106, shared with `TOGGLE_IMU_MODE`) and a
-`UART_DISABLE` (0x61–0x69). Exact codes for these are unconfirmed.
-
-### Destructive commands — *do not send*
-
-These exist on the wire but are **deliberately excluded** from `WhoopCommand`. They can wipe
-data, brick, or power-cycle the strap. NOOP must never send them.
-
-| Code | Command | Hazard |
-|-----:|---------|--------|
-| 25 | `FORCE_TRIM` | discards stored data |
-| 32 | `POWER_CYCLE_STRAP` | power-cycles (gated probe exception — see below) |
-| 36 | `START_FIRMWARE_LOAD` | firmware write |
-| 37 | `LOAD_FIRMWARE_DATA` | firmware write |
-| 38 | `PROCESS_FIRMWARE_IMAGE` | firmware write |
-| 45 | `ENTER_BLE_DFU` | enters DFU bootloader |
-| 99 | `RESET_FUEL_GAUGE` | resets battery fuel gauge |
-| 142 | `START_FIRMWARE_LOAD_NEW` | firmware write |
-| 143 | `LOAD_FIRMWARE_DATA_NEW` | firmware write |
-| 144 | `PROCESS_FIRMWARE_IMAGE_NEW` | firmware write |
-
-The 142–144 family is the high-opcode-space counterpart of 36/37/38, in the same style as the clock
-family answering at 145–147 on MAVERICK. It is named by the schema and absent from the sender enum on
-both platforms; it was missing from this table, so nothing recorded that it must stay that way. (83
-`VERIFY_FIRMWARE_IMAGE` is part of the same flow but is not itself a write, and is likewise unsent.)
-
-**Two guarded exceptions — both restarts, both non-destructive** (a restart keeps the strap's stored
-data and just re-advertises after boot). Neither is ever sent automatically or on any connect/offload path.
-
-- **`REBOOT_STRAP` (29)** — the normal Restart. NOOP already triggers a reboot today via
- `SET_ADVERTISING_NAME_HARVARD` (rename applies on reboot). In `WhoopCommand` as `rebootStrap`, sent only
- from the user-initiated, confirmation-gated "Restart strap" action (`BLEManager.rebootStrap()` /
- `WhoopBleClient.rebootStrap()`) (#166).
-- **`POWER_CYCLE_STRAP` (32)** — a harder restart, in the enum as `powerCycleStrap` **only** as a candidate
- for the WHOOP 4.0 reboot probe (below). Sent only from `rebootProbe(.powerCycle32Empty)`, itself gated
- behind Test Centre → Connection + a confirmation, and 4.0-only. Never on a default install.
-
-Everything else in this table stays out of the enum entirely.
-
-**WHOOP 4.0 reboot probe (#235).** A real 4.0 silently ignores the production `REBOOT_STRAP` frame (see
-below) and the correct 4.0 reboot frame is unknown. The probe (Test Centre → Connection, 4.0 only) sends
-one non-destructive candidate at a time — `REBOOT_STRAP(29)` empty, `POWER_CYCLE_STRAP(32)` empty, or
-`REBOOT_STRAP(29)` with `[0x01]` — reusing the reboot watchdog so the strap log shows which one drops the
-link (worked) vs is ignored. The definitive fix is still an HCI capture of the official app rebooting a
-4.0 (the way the alarm frame was pinned, #535). Driven by `BLEManager.rebootProbe(_:)` /
-`WhoopBleClient.rebootProbe(...)`; candidates enumerated in `RebootProbeVariant`.
-
-**Body-location probe (#690).** A read-only, user-triggered diagnostic (Test Centre → Connection, both
-families) that sends `GET_BODY_LOCATION_AND_STATUS` (84 / `0x54`) and dumps the strap's full raw
-COMMAND_RESPONSE to the strap log + a copyable dialog. The 4-byte inner-payload record is
-`revision · location · confidence · status`; `location` maps `0 UNKNOWN, 1 WRIST, 2 BICEP, 3 CALF,
-4 SIDE_TORSO, 5 GLUTE, 7 ANKLE, 128 NOT_CONCLUSIVE, 160 UNKNOWN_GARMENT` (any other value — including the
-gap at 6 — is kept raw; `confidence`/`status` stay raw until captures establish their semantics). Decoded
-only on WHOOP 4.0, where the inner payload starts at the command byte + 1; on 5/MG the puffin envelope's
-result code sits where `location` would land, so the raw grid is shown and the record is left undecoded
-until a real 5/MG capture maps the offset. **Never** feeds wear detection, sleep gating, or scoring.
-Driven by `BLEManager.probeBodyLocationAndStatus()` / `WhoopBleClient.probeBodyLocationAndStatus()`;
-formatted by the pure `BodyLocationProbe` twin (Swift↔Kotlin byte-parity locked by a golden test). The
-layout + enum facts are reverse-engineered from the WHOOP app and reimplemented in NOOP's own code
-(facts, not copied expression — see [`ATTRIBUTION.md`](../ATTRIBUTION.md)).
-
-**Feature-flag enumeration probe (#761, read-only).** NOOP has always been able to WRITE a feature flag
-(`SET_FF_VALUE` / 120, the R22 unlock in `Whoop5Config`) but never to ASK a strap which flags it knows.
-The `CommandNumber` table names a full symmetric read side that was never implemented — 117
-`START_FF_KEY_EXCHANGE` / 118 `SEND_NEXT_FF` for feature flags, 115 / 116 for device config — and this
-probe uses the enumerate pair only: **names, no values, nothing written.** `GET_FF_VALUE` (128) is
-deliberately not sent: the only hands-on report of it (`johnmiddleton12/wearable`, run on the author's
-own WHOOP 4.0 on fw 41.16.6.0) states its reply's value field is contaminated by a stale shared buffer,
-so an on/off read is unreliable; the same session ran the 117→118 loop and got a complete key dump.
-
-Request bodies are `[0x01]` (the inner b3 byte the SET_CONFIG family and `GET_HELLO` use); 118's body is
-a **cursor, not an index**, so the same frame is repeated to walk the list. The reply is an ordinary
-COMMAND_RESPONSE whose record sits behind the 2-byte response header (`pay[1]` is the 5/MG result code) —
-the same `pay[2]` record start `GET_BATTERY_LEVEL` and `GET_CLOCK` already decode from:
-
-| Command | Record (from `pay[2]`) |
-|---|---|
-| 117 `START_FF_KEY_EXCHANGE` | `revision u8` · `numberOfFeatureFlags u16 LE` · padding |
-| 118 `SEND_NEXT_FF` | `revision u8` · `index u8` · `validKey u8` · `key` (ASCII, NUL-terminated) · padding |
-
-**The two terminator conditions are not interchangeable, and are separated deliberately.** The walk stops
-on `index = 0xFF` — the one end marker a strap has served here unambiguously. `validKey = 0` on its own
-does NOT stop it: that could equally mark an EMPTY or RETIRED SLOT with the list continuing past it, and
-the record layout above is derived from a WHOOP 4.0 and **unverified on 5/MG**. Neither reading is
-established, because on the walks this project has, the two have never been separated on the wire: the
-117/118 walk on a WS50_r03 served sixteen replies that were all `validKey = 1` with no `0xFF` at all,
-and its 115/116 walk ended on a single reply carrying `index = 255` **and** `validKey = 0` together. So a
-`validKey = 0` entry is recorded, stepped over, and the next record verb is sent again — what comes back
-separates the two readings, and the report states which it observed. Past that the bounds are all
-CLIENT-side and each names itself in the report's `Stop code:` line: 8 consecutive `validKey = 0` replies,
-a repeated index during such a run (a parked cursor — evidence for the terminator reading), the announced
-count plus 4, or a hard cap of 128 replies. Each next-record request is only sent after the previous reply
-lands. Both CRCs are verified before any field is read; a failed CRC, a non-COMMAND_RESPONSE type, or a
-short record ends the walk with a named reason instead of a decode, and the RAW record bytes of every
-reply are logged beside the fields decoded from them. Driven by `BLEManager.probeFeatureFlags()` /
-`WhoopBleClient.probeFeatureFlags()` (user-triggered, Test Centre → Connection, both families) and
-allowlisted for 5/MG framing **only while a probe is in flight**; parsed + rendered by the pure
-`FeatureFlagProbe` / `FeatureFlagProbeReport` twins (Swift↔Kotlin byte-parity, unit-tested on synthetic
-frames). Result goes to a copyable dialog + the strap log; no storage. The field order and opcode numbers
-are facts read off a decompiled official client's response types and corroborated by that 4.0 dump,
-reimplemented in NOOP's own code — facts, not copied expression (see [`ATTRIBUTION.md`](../ATTRIBUTION.md)).
-**Unverified on 5/MG:** the published key dump is a 4.0's R19-era list; whether a 5/MG answers 117 at all
-is what the probe exists to establish (§10).
-
-**Device-config read probe (#103, read-only).** The #761 follow-up: that probe asked the strap for key
-NAMES, this one asks for a named key's VALUE — and it reaches the namespace 117/118 never covered. NOOP
-writes config through two different verbs into two different namespaces (`SET_FF_VALUE` / 120 for the
-sixteen R22 feature flags in `Whoop5Config.enableR22Sequence`, `SET_DEVICE_CONFIG_VALUE` / 119 for the
-Broadcast-HR key, #181) and has never read either. The `CommandNumber` table names the read side of both:
-121 `GET_DEVICE_CONFIG_VALUE` and 128 `GET_FF_VALUE`.
-
-**Both opcodes may simply not be implemented.** A number in the table is not a served verb — opcode 96
-(`ENTER_HIGH_FREQ_HISTORICAL_MODE`) is the standing example of one nothing in the wild sends. So the
-probe's primary deliverable is a per-verb verdict — **answered**, **rejected as UNSUPPORTED**, or
-**silent** — and a clean "neither verb is served" is a useful result, not a failure. It spends exactly one
-round-trip per verb establishing that (128 against a flag NOOP writes, 121 against the known-good
-Broadcast-HR key) before doing anything else; a verb that is refused, silent or undecodable is **retired**,
-so a dead verb costs one 8 s window rather than one per key.
-
-Only a verb that answers goes on to read values: the sixteen known flag names (whose values NOOP has only
-ever written, never read), then a short list of **guessed** oxygen-related key names against the
-device-config namespace — `DeviceConfigReadProbe.oxygenCandidateKeys`, the one constant to extend, and
-labelled as guesses everywhere they surface. That list is the #103 question in probe form: the byte at
-deep-record offset 82 reads as real SpO2 on some straps and flat `0x00` on others, which is what a
-subscription gate would look like, and a config key governing it would sit in the device-config namespace.
-
-Request body is `[0x01]` (the inner b3 byte) + the key as ASCII NUL-padded to 32 bytes — the SET side's own
-name field minus its value byte. That shape is **inferred from the SET side, not observed**; if it is wrong
-the strap answers FAILURE or nothing, which the report says plainly. The reply is an ordinary
-COMMAND_RESPONSE whose record sits behind the 2-byte response header, and **beyond that offset no field
-layout is assumed**: the record is reported as raw hex. A value is only ever *claimed* when the reply
-echoes the requested key inside a 32-byte NUL-padded field, in which case the byte immediately after that
-field is the value — the SET layout, checked rather than assumed. (On 5/MG the puffin envelope pads the
-inner payload to a 4-byte boundary, so trailing NULs in a record are envelope padding; reading "the byte
-after the echoed field" rather than "the last byte" is what keeps that out of the answer.)
-
-Read-only by construction. `DeviceConfigReadProbe.readOnlyOpcodes` is `{121, 128}` and
-`isReadOnlyOpcode` is the *same predicate* the 5/MG `send()` allowlist consults — admitting them only
-while a probe is in flight — so the "119/120 are never sent from this path" claim is a unit-tested property
-of the allowlist rather than a comment. The plan is capped at 64 round-trips. Driven by
-`BLEManager.probeDeviceConfigValues()` / `WhoopBleClient.probeDeviceConfigValues()` (user-triggered, Test
-Centre → Connection, both families); parsed + planned + rendered by the pure `DeviceConfigReadProbe` /
-`DeviceConfigReadProbeReport` twins (Swift↔Kotlin byte-parity, unit-tested on synthetic frames). Result
-goes to a copyable dialog + the strap log; no storage. The opcode numbers come from this repo's own
-protocol table (`Resources/whoop_protocol.json`). **Unverified on any strap:** nothing in this project has
-ever had 121 or 128 answered.
-
-**GET_DATA_RANGE ring backlog (#689, diagnostic only).** Beyond the oldest/newest timestamps NOOP already
-scans from a `GET_DATA_RANGE` reply, the app computes a ring-buffer page backlog from three u32s in the
-command-response inner payload (whose byte 0 is a subtype): write page `W = V(2)`, read pointer `U = V(3)`,
-ring capacity `T = V(5)`, where `V(i)` is the u32 at inner offset `i·4 + 1` (frame offsets `cmdOff + 10/14/22`
-here). Backlog with wraparound: `W < U ? W + (T − U) : W − U`. `DataRange.pagesBehind` (Swift + Kotlin twins,
-byte-parity, unit-tested for normal / wraparound / too-short / implausible) logs `Strap backlog pages behind:
-N` when it decodes plausibly — read u32 LE, guarded on frame length + a capacity sanity ceiling. **Never**
-gates sync or backfill: the layout is RE'd from the WHOOP app (facts, reimplemented in NOOP's own code, see
-[`ATTRIBUTION.md`](../ATTRIBUTION.md)) but **not yet confirmed against real 4.0 / 5-MG captures**, so it stays
-a log-only diagnostic until a fixture pins the offsets + endianness.
-
-**Payload forms** (decoded from the official app's command builders — recorded so the wire format is
-*known*: for the destructive commands, known-and-avoidable; for the one guarded exception,
-`REBOOT_STRAP`, known-and-used by `rebootStrap()`). The opcodes are shared across WHOOP 4 (harvard)
-and WHOOP 5/MG (puffin): the app's unified command enum (`EnumC58479e`) uses the same `25`/`29`/`32`
-on both transports — unlike haptics, which has a maverick-specific `0x13`.
-
-- `FORCE_TRIM` (25) — body is **two little-endian int32 range args**. The app's "erase everything"
- form sets both to `-16843010` (`0xFEFEFEFE`), an 8-byte sentinel that trims the entire stored
- range (builder `rh0.C45484g`: `new C45484g(-16843010, -16843010)`). It is **not** an empty/`[0x00]`
- payload. This wipes the rolling ~14-day flash history — anything not already offloaded is gone.
-- `REBOOT_STRAP` (29) — **empty body** (builder `rh0.C45476d0` passes a null payload). The strap drops
- the BLE link and re-advertises after boot; stored data is kept. Non-destructive, but interrupts any
- in-flight offload. **WHOOP 5.0 (puffin): hardware-confirmed** — the empty-body frame reboots a 5.0
- (fw 50.40.1.0, #227). **WHOOP 4.0 (harvard): NOT confirmed** — a real 4.0 silently ignores this
- empty-body frame (#235: no reboot, no disconnect, no COMMAND_RESPONSE), so the correct 4.0 form (a
- payload byte? a different opcode?) still needs an HCI capture of the official app rebooting a 4.0.
-
----
-
-## 7. Historical-data offload (backfill)
-
-The type-47 store is the strap's rolling ~14-day biometric history and is NOOP's **primary**
-metric source (it is re-offloaded every 15 minutes while connected, mirroring WHOOP). An offload
-is bracketed by `METADATA` (type 49) control frames and acknowledged chunk-by-chunk so the strap
-can safely trim what it has handed over.
-
-### 7.1 MetadataType (`METADATA[6]`)
-
-`enums.MetadataType` in `whoop_protocol.json`; classified by `classifyHistoricalMeta(_:)`
-(`HistoricalMeta.swift`).
-
-| Value | Name | Meaning |
-|------:|------|---------|
-| 1 | `HISTORY_START` | offload beginning; start accumulating a chunk |
-| 2 | `HISTORY_END` | chunk boundary; carries the trim cursor — **ack to advance** |
-| 3 | `HISTORY_COMPLETE` | offload finished; close the session |
-
-### 7.2 `HISTORY_END` payload layout
-
-The `metadata` post-hook decodes the payload (which begins at `frame[7]`, after `[type][seq]
-[cmd]`) as `struct '
+
+
+
+
+
+
+
+## Scope and compatibility
+
+WHOOP 4 and WHOOP 5/MG share protocol concepts, but have different GATT services,
+frame headers and connection flows. A shared command name does not establish the
+same request or response bytes. WHOOP 5 historical data is not a WHOOP 4 layout
+with shifted offsets.
+
+The WHOOP 5/MG topic references use **firmware 50.42.1.0** as their common baseline
+unless a passage explicitly identifies an earlier observation or a client-only
+interpretation. The compared WHOOP 5 and MG firmware images were byte-identical;
+this supports one shared profile, while ECG still depends on hardware capability.
+It does not establish identical images across every release or identical features
+on both devices. These baseline contracts do not claim device validation.
+
+Earlier NOOP observations cover ECG on MG **50.39.1.0**, optical/IMU decoding and
+reboot on **50.40.1.0**, R-R conversion on **50.41.1.0**, and older Hello decoding
+on **50.38.1.0**. The WHOOP 4 enumeration report used **41.16.6.0**. Client-only IMU
+stream descriptions refer to Android app **5.465.0**. Their distinct limits remain
+at the relevant operation; they do not override the baseline. WHOOP 4 is a legacy
+implementation/capture profile, with no equivalent complete command coverage here.
+
+| Profile | Start here | Interpretation |
|---|---|---|
-| Serial prefix `5AM` | Serial Number String (`0x2A25`) | MG |
-| Serial prefix `5AG` | Serial Number String (`0x2A25`) | 5.0 |
-| Hardware revision contains `WG50` | Hardware Revision String (`0x2A27`) | 5.0 |
-
-Contradictory signals resolve to `.unknown` rather than a guess, and `.unknown` is not MG — an MG-only
-feature stays gated off until the hardware attests to it. Only the 5.0 hardware string is attested on
-real hardware so far; the MG's own revision string is not, so its absence proves nothing.
-
-### 9.1 ECG ("Labrador") on the MG
-
-The MG's ECG subsystem is called **Labrador** in the protocol tables. It is its own realtime data type,
-**not** an R-numbered `StrapSensorData` layout: there is a FILTERED stream (live, display-ready) and a
-RAW stream the strap persists for later offload.
-
-> **Not a medical feature.** NOOP is not a medical device. The strap runs its own embedded rhythm
-> classifier and ships the verdict in every packet; NOOP decodes that byte and nothing more. It is
-> unvalidated instrumentation, never a measurement and never a diagnosis. See
-> [`../DISCLAIMER.md`](../DISCLAIMER.md).
-
-**Commands — a working hypothesis, not a confirmed mapping.** All four numbers are already in
-`CommandNumber` (§6's table, from the upstream whoomp/goose work). Payload is `[revision, arg]` with
-`revision = 0x01`, carried in the normal puffin envelope; `puffinCommandFrame`'s pad4 supplies the
-command struct's trailing `padding` field.
-
-Three reasons the numbers are **not** settled, all of which the on-hardware probe is meant to resolve:
-
-1. **Four codes, five names.** §6 lists five ECG/HeartKey names; only four are mapped here.
- `ECG_SEND_RAW` is unaccounted for, so at least one of the four could be carrying the wrong name.
-2. **139 (0x8B) is not contiguous** with 123–125, unlike the rest of the family.
-3. **The table is 4.0-derived and 5/MG is known to remap opcodes.** §6's "Additional 5-class command
- numbers" already records MAVERICK answering `SET_CLOCK` at 146 and `GET_CLOCK` at 147 rather than
- 10/11. So a 4.0-sourced number is not automatically the 5/MG number. This matters more than usual
- here: `CommandNumber`'s immediate neighbours in that range are
- **142 `START_FIRMWARE_LOAD_NEW` / 143 `LOAD_FIRMWARE_DATA_NEW` / 144 `PROCESS_FIRMWARE_IMAGE_NEW`** —
- the destructive family §6's "do not send" section excludes. NOOP never forms those bytes (they are
- absent from `WhoopCommand` entirely, so the command sender cannot express them), but anyone probing
- this space by hand should know what sits three codes above 139 before widening a sweep.
-
-| Code | Command | Arg | Reversible? |
-|-----:|---------|-----|---|
-| 123 (0x7B) | `SELECT_WRIST` | `0` right / `1` left — **inferred from enum order, unconfirmed** | **Persistent device config** — survives disconnect; re-writable |
-| 124 (0x7C) | `TOGGLE_LABRADOR_DATA_GENERATION` | `1` stop / `2` start — `0` is REFUSED (`FAILURE(0)`, generation unchanged). Attested on one MG (`WS50_r00`, fw `50.39.1.0`); the earlier `0`/`1`/`2` reading came from the client's enum order | yes — `124 = 1` is the OFF path, and `139 = 0` also stops it |
-| 125 (0x7D) | `TOGGLE_LABRADOR_RAW_SAVE` | `0`/`1` | yes |
-| 139 (0x8B) | `TOGGLE_LABRADOR_FILTERED` | `0`/`1` | yes |
-
-Documented turn-on order: `SELECT_WRIST` → filtered on → raw-save on → data generation `start`. NOOP
-splits `SELECT_WRIST` into its own separately-confirmed action, because it is the only one that writes
-strap state outliving the session **and** its value mapping is unattested.
-
-**Packet layouts.** Both open with the same 17-byte status block (multi-byte fields little-endian):
-
-| Off | Size | Field |
-|----:|-----:|-------|
-| 0 | 1 | `signalQuality` (0 unknown / 1 low / 2 medium / 3 high) |
-| 1 | 1 | `statusFlags` |
-| 2–5 | 1 each | `heartKeyStarted`, `heartKeyIsRunning`, `heartKeyIsStoppedAndComplete`, `heartKeyLeadsAreOn` (Bool) |
-| 6 | 1 | `heartKeyArrhythmiaCheckResult` (0 notComplete, 1 normalSinusRhythm, 2 signalUnreadable, 3 bradycardia, 4 afibDetected, 5 tachycardia, 6 inconclusive) |
-| 7 | 1 | `heartKeyArrhythmiaCheckStatus` (0 notRunning / 1 inProgress / 2 checkComplete) |
-| 8 | 1 | `heartKeyProgress` (percentage; the source type also has a timed-out case whose sentinel value is unattested) |
-| 9 | 1 | `heartKeyUnreadableReason` |
-| 10 | 1 | `heartKeyAverageHR` |
-| 11 | 1 | `heartKeyHR` |
-| 12 | 2 | `heartKeyHRV` (u16) |
-| 14 | 1 | `heartKeyStressScore` |
-| 15 | 2 | `numberOfECGSamples` (u16) |
-
-`FilteredLabradorPacket` then carries `numberOfECGSamples` × **i16** `filteredECGDataRaw`, then padding.
-`RawLabradorPacket` carries an opaque `rawECGDataRaw` blob, then `numberOfLeadsOffSamples` (u8), then
-`leadsOffIRaw` and `leadsOffQRaw` (u16 arrays of that length), then padding. The raw blob's
-bytes-per-sample is `count ÷ numberOfECGSamples` — i.e. its **length is not on the wire**, so
-`Whoop5Ecg.decodeRaw` takes the width explicitly and `rawBytesPerSampleCandidates` enumerates what a
-buffer admits rather than guessing.
-
-**What is not established.** The packet TYPE byte these records arrive under — no capture exists and
-§3's table has no Labrador entry — so `Whoop5Ecg` decodes a payload and the app hunts for the type
-empirically with a structural triage, logging candidates. The wrist enum's raw values, the timed-out
-sentinel, and the ECG sample unit/scale are likewise unattested and are carried raw.
-
-**Gating.** The hardware gate is MG-only and non-bypassable; entitlement and feature-flag gates are
-client-side. Whether the strap ALSO refuses the feature is a separate question that only the strap's own
-behaviour can answer. `Whoop5EcgProbe` separates the observable cases from the COMMAND_RESPONSE result
-code at `frame[12]` (0 FAILURE / 1 SUCCESS / 2 PENDING / 3 UNSUPPORTED): `UNSUPPORTED` means the opcode
-is not implemented, `FAILURE` means the firmware knows the opcode and refused to run it, and
-all-`SUCCESS` with zero packets arriving means acknowledged and then not honoured. Silence alone is
-never read as evidence of anything.
-
-**The verdicts name observations, not mechanisms — and this was got wrong once.** Earlier wording
-attributed both the refusal and the silence to a firmware `WhoopDeviceFlag` layer returning
-`blockedByDeviceFlags`. **That is a client-side construct.** No command in `whoop_protocol.json`'s
-`CommandNumber` table reads or writes such a flag, nothing in this repo implements one, and it is never
-transmitted to a strap — so it is not a strap capability gate and a probe that sees only result codes
-and packet counts cannot attribute silence to it. The reply carries *that* the firmware refused, never
-*why*. #891 then tested the leading named firmware-side candidate — `enable_raw_data_w_ecg`, written to
-`'1'` through `SET_DEVICE_CONFIG_VALUE(119)` and read back through `GET_DEVICE_CONFIG_VALUE(121)` — and
-still saw zero packets in 30 s with the electrodes held, which falsified it. Five explanations remain
-live for that silence: data banked to flash rather than streamed (one toggle is literally `RAW_SAVE`), a
-wrong opcode mapping, no actual start verb among three `TOGGLE_*` commands, an entitlement gate, and an
-electrode circuit that never closed.
-
-**Both silence-interpreting verdicts are scoped to what the run actually asked for.** A verdict that
-reads silence as informative is only reachable when the run exercised the ECG data path, which
-`Whoop5Ecg.requestsRealtimeData` decides from the opcode AND the argument sent: `SELECT_WRIST` configures
-and starts nothing on either argument, the OFF sequence asks for the silence it gets, and `RAW_SAVE`
-names flash rather than a live channel. So a run built only from those reports "no data-generation
-command was sent; this run cannot speak to whether ECG is blocked", and a `FAILURE` on one of them
-reports as a refusal of that write. Without the scoping a `SELECT_WRIST`-only run rendered as a
-device-flag block on real hardware — twice, once through each verdict — which is what #891 records.
-
-## 10. SpO₂ on 5.0 / MG — what the wire does and does not carry
-
-Recorded because "why is there no blood oxygen?" is a recurring question with a protocol answer.
-
-- **No SpO₂ read opcode is known.** Our `CommandNumber` catalogue carries 80 commands and none is an
- oxygen/blood-oxygen read; independent RE reports none either. Note the catalogue is what we have
- mapped, not a proof of the strap's whole command space — §6 is explicitly a *safe subset*, and the
- 98-vs-87 battery dispute shows the map is incomplete. Treat it as "nobody has found one", which is
- still enough to say hunting for a missing opcode is the wrong lead.
-- **It is computed on-device, during sleep.** Our own decode corroborates the gating: `aux_byte_82` is
- observed nonzero *only* while the band sleep flag reads asleep. Expect values in overnight windows,
- never a continuous 24/7 series.
-- **The export is a per-cycle aggregate.** `blood_oxygen_pct` arrives on the physiological-cycles row —
- our own importer reads it beside `recovery_score_pct` and `day_strain`, keyed on
- `cycleStart`/`cycleEnd` (`WhoopExportImporter.swift:272`) — so it is one value per recovery cycle and
- will not equal a plain mean of raw wire samples. Rounding, quality gates and incomplete nights all
- move it.
-- **A night with no export value is a real gap**, not a NOOP bug — naps and incomplete nights are
- reported to carry none. (Contributor observation from #807, not something this repo can verify from
- the wire; recorded because "my SpO₂ is missing" reads as a decode failure otherwise.)
-
-- **Whether a firmware FLAG gates it is now answerable from the strap itself.** The read-only
- feature-flag enumeration probe (§6, #761) asks the strap to list the flag names its own firmware
- knows. A 5/MG list with no oxygen-related key is evidence Blood Oxygen is not client-writable at all;
- a list containing one is the answer outright. That is a direct read, not an inference from a byte that
- happens to be zero — the same move the Oura `spo2_status` probe already makes for the ring.
-
-So the research target is finding the banked on-device sample in the historical type-47 record — not
-inventing a red/IR ratio or reversing a calibration curve. The v18 `@82` candidate and its split
-cross-device evidence are covered in
-[`WHOOP5_DEEP_DATA.md`](WHOOP5_DEEP_DATA.md); the full v18 field map lives in
-[`BLE_REVERSE_ENGINEERING.md`](BLE_REVERSE_ENGINEERING.md#the-whoop-50-type-47-record-version-18) and
-is deliberately **not** duplicated here — one table, one place to keep correct.
-
-## 11. File map
-
-| Path | Responsibility |
-|------|----------------|
-| `Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift` | SOF/length/CRC8/CRC16/CRC32, `verifyFrame`, `Reassembler`, `frameFromPayload` |
-| `Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift` | `parseFrame` (4.0 + 5.0), `ParsedFrame`, field builder |
-| `Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceFamily.swift` | UUID strings, header-CRC kind, `CLIENT_HELLO`, puffin aliasing |
-| `Packages/WhoopProtocol/Sources/WhoopProtocol/Schema.swift` | JSON schema model + `loadSchema()` |
-| `Packages/WhoopProtocol/Sources/WhoopProtocol/PostHooks.swift` | per-type irregular-field decoders |
-| `Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift` | `classifyHistoricalMeta` (START/END/COMPLETE) |
-| `Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json` | canonical enums + packet layouts |
-| `Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift` | MG ECG ("Labrador") packet decode + command construction (§9.1) |
-| `Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5EcgProbe.swift` | ECG turn-on report + the run-scoped result-code verdicts (§9.1) |
-| `Strand/BLE/BLEManager.swift` | CoreBluetooth transport, bond, connect lifecycle, backfill orchestration |
-| `Strand/BLE/Commands.swift` | safe `WhoopCommand` set + outbound frame builder |
-| `Strand/BLE/FrameRouter.swift` | decode → `LiveState` (UI) |
-| `Strand/BLE/StandardHeartRate.swift` | `0x2A37` HR/R-R parser |
-| `Strand/Collect/Backfiller.swift` | historical-offload state machine + safe-trim invariant |
-
----
-
-*Reverse-engineering credit: `johnmiddleton12/my-whoop` (WHOOP 4.0) and `b-nnett/goose`
-(WHOOP 5.0). This is an independent interoperability project for the user's own device and data;
-it is not affiliated with WHOOP and is not a medical device.*
-
-### `GET_HELLO_HARVARD` (35) response — the WHOOP 4.0 serial
-
-A 4.0 exposes no DIS Serial Number String (`0x2A25`), so this response is the only place its stable
-serial appears. In the captures on record the response payload (sliced past `SOF+len+crc8` and
-`[type,seq,cmd,origin_seq,result]`, i.e. from byte 9 of the frame) is **131 bytes** and carries two
-alphanumeric runs:
-
-| payload offset | length | what |
-|---|---|---|
-| 14 | 9 | **strap serial** — the stable per-device id (`Whoop4HelloSerial`) |
-| 24 | 54 | **device key** — a secret; never read it, never log it, never let it become an id |
-
-`Whoop4HelloSerial` reads a FIXED 9-byte window at offset 14 for exactly this reason: a scanning
-"longest alnum run" could drift onto the key as payloads vary, and a fixed window cannot.
-
-**Provenance, because it changes how much this should be trusted:** the offsets come from a single
-capture, not from documentation. They are corroborated only in the sense that two independent places in
-the codebase record the same layout — which is one observation written down twice, not two
-observations. Treat a strap that stops adopting as evidence the field moved, rather than assuming the
-table is wrong about the shape. This is why the 4.0 adoption path waits for the same value on two
-separate hellos before acting on it (`RepeatedSerialGate`), where a 5/MG adopts its spec-defined DIS
-serial on first read.
-
+| WHOOP 4 | [WHOOP 4 profile](PROTOCOL_WHOOP4.md) | Legacy connection, framing, identity and record conventions |
+| WHOOP 5 / MG | [WHOOP 5 / MG profile](PROTOCOL_WHOOP5.md) | Shared transport; check capabilities independently |
+| All readers | [Shared concepts](PROTOCOL_CONCEPTS.md) | Integrity, request lifecycle and durable history handling |
+
+Command revision, record layout and inner record version remain explicit byte
+selectors throughout the reference. Unknown means unresolved, not unsupported.
+“Defined” does not mean available in every state or fully implemented by NOOP.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Reading guide
+
+| Task | Authoritative topic |
+|---|---|
+| Frame, correlate and recover a connection | [Transport](PROTOCOL_TRANSPORT.md) |
+| Find an operation or its limitations | [Command reference](PROTOCOL_COMMANDS.md): 159 IDs, 71 defined and 88 unsupported in the baseline context |
+| Configure collection and output | [Configuration](PROTOCOL_CONFIGURATION.md): 8 device keys and 25 feature descriptors |
+| Decode measurements | [Sensor records](PROTOCOL_SENSORS.md) |
+| Control and decode MG ECG | [ECG](PROTOCOL_ECG.md) |
+| Schedule or stop an alarm | [Alarms](PROTOCOL_ALARMS.md) |
+| Understand image transfer and authorization | [Updates and authorization](PROTOCOL_UPDATES.md) |
+| Work on NOOP’s integration | [Implementation and historical observations](PROTOCOL_IMPLEMENTATION.md) |
+| Reproduce selected parsing rules | [Constructed examples](protocol-examples/validate_examples.py) |
+
+Each contract has one authoritative topic. Historical experiments, client timers
+and decoder conventions are labeled separately. In particular, the older
+[deep-data experiment](WHOOP5_DEEP_DATA.md) is not a universal enable recipe.
+
+## Remaining boundaries
+
+Absolute ECG sample timing and voltage calibration, some physiological field
+meanings, complete bootloader acceptance and several runtime/error interactions
+remain unresolved. Local limits are recorded beside each contract. Constructed
+examples check selected arithmetic and state rules; they are not device tests.
+
+
+
+## Project and credits
+
+NOOP is an independent, offline companion and is not affiliated with WHOOP or a
+medical device. See [disclaimer](../DISCLAIMER.md) and [attribution](../ATTRIBUTION.md).
+The existing work builds on `johnmiddleton12/my-whoop` (WHOOP 4) and
+`b-nnett/goose` (WHOOP 5); further credits remain with the historical observations.
+The Swift protocol package and Android implementation are indexed in the
+[file map](PROTOCOL_IMPLEMENTATION.md#11-file-map).
diff --git a/docs/PROTOCOL_ALARMS.md b/docs/PROTOCOL_ALARMS.md
new file mode 100644
index 0000000000..b6d91364c4
--- /dev/null
+++ b/docs/PROTOCOL_ALARMS.md
@@ -0,0 +1,102 @@
+# WHOOP alarm configuration and execution
+
+Applicability: [central scope and compatibility](PROTOCOL.md#scope-and-compatibility).
+
+This companion to the [command reference](PROTOCOL_COMMANDS.md#haptics-and-alarms) applies to the reference baseline. Earlier device observations remain separately scoped; the contracts below do not establish a successful physical wake on this version.
+
+
+Alarm configuration supports six IDs, 1–6. SET uses revision 4 and the following 21-byte body; GET takes `[4, ID]` and, for a valid ID, returns the same 21-byte record. Multibyte fields are little-endian.
+
+| Offset | Width | Field |
+|---:|---:|---|
+| 0 | 1 | Revision 4 |
+| 1 | 1 | Alarm ID |
+| 2 | 4 | Epoch seconds |
+| 6 | 2 | Fractional ticks, using the clock's 1/32768-second convention |
+| 8 | 8 | Waveform effects |
+| 16 | 2 | Effect-loop control |
+| 18 | 1 | Overall repeat count |
+| 19 | 1 | Duration |
+| 20 | 1 | Crescendo: 0 ordinary pattern, 1 staged crescendo control |
+
+The earlier NOOP 20-byte alarm model receives a zero byte at offset 20 from format-1 padding. With the same sequence and other fields, it produces the same padded body as explicitly supplying crescendo zero. The newly described field does not show that earlier transmitted requests were too short or explain an unsuccessful wake by itself.
+
+SET requires seconds strictly later than the strap's current seconds: a larger fractional value within the same second is insufficient. Each effect byte must be at most 251, repeat count must be below 8, and crescendo must be 0 or 1. When repeats equal 7, duration must be 30–120 inclusive; this interval is not imposed by this validator for repeats 0–6. A parser-accepted effect or duration is not a guarantee of a useful physical waveform. The full effect vocabulary, physical intensity and every operating limit remain unresolved.
+
+SET's response body is `[4, validation detail]`. This detail is distinct from the outer command result:
+
+| Detail | Meaning |
+|---:|---|
+| 1 | Input passed validation |
+| 2 | Effect byte out of range |
+| 3 | Repeat count out of range |
+| 4 | Duration out of range for repeat count 7 |
+| 10 | Time is not later than the current whole second |
+| 11 | Alarm ID out of range |
+| 12 | Crescendo out of range |
+
+Validation failures return outer failure. Detail 1 can accompany outer success **or failure**, because saving the record can still fail. The alarm-set event 56 is not an independent proof that storage succeeded.
+
+SET saves the pattern portion before the time portion. A pattern-write failure prevents the time write; a time-write failure can leave the new pattern with the previous schedule. Alarm records use nonvolatile storage, but writes are not established as atomic and survival of a particular power interruption has not been validated. GET reloads storage, yet failed reads can substitute zeros while GET still reports success. An all-zero time can therefore mean cleared/unset state or a storage-read fallback. Do not treat it as a separate storage-health result. Invalid-ID/revision failure bodies are not usable alarm records.
+
+### Disable, due processing and manual run
+
+DISABLE uses `[2, ID]` for one alarm or `[2,255]` for all six. Its outer result reflects storage success/failure, and its body carries revision 2. All-ID disable attempts every slot even after an individual write fails, retaining a failure result if any write fails. Failure can therefore follow partial clearing; read back individual slots when their state matters. Disabling clears the saved record; it does not substitute for stopping an already active haptic effect.
+
+Due processing compares the alarm's whole seconds with the strap clock. Fractional ticks are retained but do not make this due check subsecond-precise. The scan cadence is nominally about half a second; exact intervals and worst-case latency remain unresolved. A due alarm is copied into active state and its stored record is cleared before haptic completion: it is a **one-shot schedule**, not a daily recurrence. Repeat count controls waveform repetition. Storage-clear failure remains separate. Simultaneous due slots share one execution context: the highest due ID in the ascending scan replaces the shared fields; independent simultaneous playback is not guaranteed.
+
+RUN uses `[2, ID]` and requires an existing nonzero stored time. It does not apply SET's future-time check. A valid initial response is outer pending with body `[2,0]`; an invalid/unset ID fails with `[2,11]`. The later response body is `[2, detail]`, with outer success only when detail equals 5. Timeout returns failure with `[2,7]`. The timeout's wall-clock duration is not specified here. RUN's active execution path also clears the selected saved record, so it is not a guaranteed nondestructive preview of a future schedule.
+
+Strap-triggered execution emits event 57; app-triggered RUN emits event 58. Ordinary pattern and crescendo paths are distinct. These events identify execution requests; they do not independently prove motor movement or that a person woke up. Haptic callbacks, driver errors, concurrent UI work and full crescendo timing still impose unresolved boundaries. Earlier alarm arming acknowledgements remain useful but do not establish an observed strap-driven wake.
+
+
+## Busy execution and stop completion
+
+Alarm schedules share one active execution context. When multiple alarms become
+due in the same scan, the last slot scanned replaces the shared alarm fields;
+separate execution of every due alarm is not guaranteed. Avoid overlapping
+schedules and overlapping manual haptic requests.
+
+An active alarm can still allow schedule scanning. A newly due alarm may be
+consumed while its start request is ignored by the busy state. Its fields may
+also replace fields used by the active execution. A successful schedule write
+therefore does not guarantee later vibration.
+
+Stopping haptics is asynchronous. Distinguish the initial pending response from
+the final result. Stop completion and start completion have different success
+conditions; the stop response contains the revision alone. Stopping the current
+effect does not disable stored alarms or establish that no pending request remains.
+
+The notification-haptic body contains a revision, eight effect bytes, a
+little-endian loop-control field and an overall repeat byte. The start response
+contains revision and detail. Preserve the operation-specific response layout.
+
+Crescendo uses staged control. Physical intensity, reliable stage timing and
+motor output are not established by an accepted request. Short durations are
+not established safe crescendo settings. In a later stage, the remaining duration
+is calculated as unsigned 32-bit `duration - 20`, then multiplied by ten for a
+timer count with the same wrapping arithmetic; a zero count becomes one. Durations
+below 20 are not rejected when repeats are below 7, so acceptance does not prevent
+this underflow. An independently armed total-duration stop can intervene first;
+the arithmetic is not evidence of an extremely long physical buzz.
+
+A start timeout can submit up to three retries before returning timeout detail 7.
+The app must not interpret each retry as a separate alarm or immediately issue
+another start while awaiting the correlated completion. Complete driver callback
+behavior and the first crescendo stage's duration remain unresolved.
+
+Schedules are read from storage during scanning. After restart, execution still
+depends on readable retained schedules, a correct clock and the application
+reaching the scanning state. Reboot survival and wake latency require separate
+validation. No automatic ECG or sensor-request cleanup on disconnect is promised.
+
+Command 122 takes revision 1 alone. Initial pending and final success/failure all carry the one-byte body `[1]`; unsupported revision also fails with `[1]`. The final result is operation-specific and must not be inferred from the start response detail. Command 19 takes 12 bytes and returns revision plus detail; its final success uses detail 5, as does manual RUN.
+
+NOOP’s single-notification command-19 body is `01 2F 98 00 00 00 00 00 00 00 00 00`:
+revision 1, effects `[47,152,0,0,0,0,0,0]`, zero effect-loop control and zero repeats.
+
+
+The nominal half-second scan scale comes from five received timer ticks followed
+by event dispatch. Interrupt handling restarts the timer, so this is not an exact
+free-running scan period, motor-start deadline or wake guarantee. Oscillator error,
+interrupt handling and scheduling can affect the actual interval.
diff --git a/docs/PROTOCOL_COMMANDS.md b/docs/PROTOCOL_COMMANDS.md
new file mode 100644
index 0000000000..e202c1fbcf
--- /dev/null
+++ b/docs/PROTOCOL_COMMANDS.md
@@ -0,0 +1,279 @@
+# WHOOP complete command reference
+
+Applicability: [central scope and compatibility](PROTOCOL.md#scope-and-compatibility).
+
+This reference extends [the existing protocol documentation](PROTOCOL.md); it is not a list of commands that NOOP automatically sends. The catalog covers **all IDs 1–159 in the documented command context**: 71 have a defined operation and 88 use the unsupported response. “Defined” does not mean fully decoded, available on every WHOOP 5/MG hardware variant, permitted in every state, or successfully exercised on a device. There are no device-validation claims for the reference baseline here; earlier observations are labeled with their own scope.
+
+Names are identifiers, not sufficient evidence of behavior. Historical names are retained for recognition even where the current version does not support the operation. An unnamed unsupported ID has no assigned semantics. Do not extrapolate this catalog to other firmware versions, command contexts or IDs outside the range.
+
+Requests below describe semantic command bodies, excluding outer padding. Unless stated otherwise, exact request/response bytes, initial state, prerequisite, persistence and reversal remain unknown. Common results and request-origin correlation are in [transport behavior](PROTOCOL_TRANSPORT.md#responses-and-correlation). An accepted request is not proof that its eventual effect occurred.
+
+## All command IDs
+
+Each numeric ID appears once in this catalog. **D** means defined, with the stated limits and linked contract; **U** means unsupported in the documented command context: result 3, empty semantic body. A known name on a U row is a historical identifier, not a current supported effect.
+
+| ID | Name / identifier | Status | Meaning and contract |
+|---:|---|:---:|---|
+| 1 | `LINK_VALID` | D | Fixed acknowledgement; not identity. [Details](#core-command-contracts) |
+| 2 | `GET_MAX_PROTOCOL_VERSION` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 3 | `TOGGLE_REALTIME_HR` | D | Live HR toggle; older NOOP body `0`/`1`, current acceptance unresolved. [Details](#core-command-contracts) |
+| 4 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 5 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 6 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 7 | `REPORT_VERSION_INFO` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 8 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 9 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 10 | `SET_CLOCK_DEPRECATED` | D | Deprecated clock setter; do not use the high-opcode body. [Details](#core-command-contracts) |
+| 11 | `GET_CLOCK_DEPRECATED` | D | Deprecated clock reader; current reply layout unresolved. [Details](#core-command-contracts) |
+| 12 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 13 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 14 | `TOGGLE_GENERIC_HR_PROFILE` | D | Boolean generic-HR policy; nonvolatile setting, downstream GATT effect unresolved. [Details](#core-command-contracts) |
+| 15 | `Forget bonds` | D | Remove pairing bonds; destructive lifecycle change. [Details](#service-and-sensitive-operations) |
+| 16 | `TOGGLE_R7_DATA_COLLECTION` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 17 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 18 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 19 | `RUN_HAPTIC_PATTERN_MAVERICK` | D | Notification haptics; revision-1 pattern. [Details](#haptics-and-alarms) |
+| 20 | `ABORT_HISTORICAL_TRANSMITS` | D | Stop historical transmission, not trim. [Details](#core-command-contracts) |
+| 21 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 22 | `SEND_HISTORICAL_DATA` | D | Request historical transmission; delivery is asynchronous. [Details](#core-command-contracts) |
+| 23 | `HISTORICAL_DATA_RESULT` | D | Acknowledge a committed history chunk; permits reclamation. [Details](#core-command-contracts) |
+| 24 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 25 | `FORCE_TRIM` | D | Force history trimming; invasive cursor mutation. [Details](#service-and-sensitive-operations) |
+| 26 | `GET_BATTERY_LEVEL` | D | Asynchronous battery query; four-byte u32 whole-percent final body. [Details](PROTOCOL_TRANSPORT.md#battery-level--command-26) |
+| 27 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 28 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 29 | `REBOOT_STRAP` | D | Reboot and interrupt current work. [Details](#service-and-sensitive-operations) |
+| 30 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 31 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 32 | `POWER_CYCLE_STRAP` | D | Power-cycle; current runtime and preservation guarantees unresolved. [Details](#service-and-sensitive-operations) |
+| 33 | `SET_READ_POINTER` | D | Change history read position; invasive cursor mutation. [Details](#service-and-sensitive-operations) |
+| 34 | `GET_DATA_RANGE` | D | Pending then 65-byte range reply; cursor roles and remaining clock limits specified. [Details](PROTOCOL_TRANSPORT.md#data-range--command-34) |
+| 35 | `GET_HELLO_HARVARD` | D | Legacy Hello branch does not build a local command reply. [Details](PROTOCOL_TRANSPORT.md#hello--command-145) |
+| 36 | `START_FIRMWARE_LOAD` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 37 | `LOAD_FIRMWARE_DATA` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 38 | `PROCESS_FIRMWARE_IMAGE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 39 | `SET_LED_DRIVE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 40 | `GET_LED_DRIVE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 41 | `SET_TIA_GAIN` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 42 | `GET_TIA_GAIN` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 43 | `SET_BIAS_OFFSET` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 44 | `GET_BIAS_OFFSET` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 45 | `ENTER_BLE_DFU` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 46 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 47 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 48 | `SEND_EVENT_PACKETS` | D | Toggle event delivery, not a proven flush of stored events. [Details](#core-command-contracts) |
+| 49 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 50 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 51 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 52 | `SET_DP_TYPE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 53 | `FORCE_DP_TYPE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 54 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 55 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 56 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 57 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 58 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 59 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 60 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 61 | `SET_AFE_PARAMETERS` | D | Set AFE channel/setting/value; operating AFE required. [Details](PROTOCOL_CONFIGURATION.md) |
+| 62 | `GET_AFE_PARAMETERS` | D | Read cached AFE channel/setting/value; three-word body, no revision prefix. [Details](PROTOCOL_CONFIGURATION.md) |
+| 63 | `SEND_R10_R11_REALTIME` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 64 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 65 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 66 | `SET_ALARM_TIME` | D | Set revision-4 time, pattern and crescendo for ID 1–6. [Details](#haptics-and-alarms) |
+| 67 | `GET_ALARM_TIME` | D | Read revision-4 21-byte alarm record; storage fallback caveat. [Details](#haptics-and-alarms) |
+| 68 | `RUN_ALARM` | D | Run a stored alarm; pending/final response, consumes saved schedule. [Details](#haptics-and-alarms) |
+| 69 | `DISABLE_ALARM` | D | Clear one saved alarm or all six; distinct from stopping active haptics. [Details](#haptics-and-alarms) |
+| 70 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 71 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 72 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 73 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 74 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 75 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 76 | `GET_ADVERTISING_NAME_HARVARD` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 77 | `SET_ADVERTISING_NAME_HARVARD` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 78 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 79 | `RUN_HAPTICS_PATTERN` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 80 | `GET_ALL_HAPTICS_PATTERN` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 81 | `START_RAW_DATA` | D | Start raw production; separate from saving and streaming. [Details](PROTOCOL_CONFIGURATION.md) |
+| 82 | `STOP_RAW_DATA` | D | Stop raw production; not a substitute for every collection policy. [Details](PROTOCOL_CONFIGURATION.md) |
+| 83 | `VERIFY_FIRMWARE_IMAGE` | D | Incremental integrity check with correlated asynchronous final result; boot acceptance separate. [Details](#service-and-sensitive-operations) |
+| 84 | `GET_BODY_LOCATION_AND_STATUS` | D | Revision 1; fixed four-byte cached status, with location/confidence placeholders. [Details](#ordinary-service-commands) |
+| 85 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 86 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 87 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 88 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 89 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 90 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 91 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 92 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 93 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 94 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 95 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 96 | `ENTER_HIGH_FREQ_SYNC` | D | Request high-frequency sync schedule; period/duration constraints below. [Details](#core-command-contracts) |
+| 97 | `EXIT_HIGH_FREQ_SYNC` | D | Request leaving high-frequency sync; acceptance precedes asynchronous effect. [Details](#core-command-contracts) |
+| 98 | `GET_EXTENDED_BATTERY_INFO` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 99 | `RESET_FUEL_GAUGE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 100 | `CALIBRATE_CAPSENSE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 101 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 102 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 103 | `Disable BLE UART` | D | Change BLE UART service state; safe readback unresolved. [Details](#service-and-sensitive-operations) |
+| 104 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 105 | `TOGGLE_IMU_MODE_HISTORICAL` | D | IMU session saving contribution in RAM. [Details](PROTOCOL_CONFIGURATION.md) |
+| 106 | `TOGGLE_IMU_MODE` | D | Live IMU transport toggle. [Details](PROTOCOL_CONFIGURATION.md) |
+| 107 | `ENABLE_OPTICAL_DATA` | D | Optical session saving contribution in RAM. [Details](PROTOCOL_CONFIGURATION.md) |
+| 108 | `TOGGLE_OPTICAL_MODE` | D | Live optical transport toggle. [Details](PROTOCOL_CONFIGURATION.md) |
+| 109 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 110 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 111 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 112 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 113 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 114 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 115 | `START_DEVICE_CONFIG_KEY_EXCHANGE` | D | Reset device-key enumeration and return count. [Details](PROTOCOL_CONFIGURATION.md) |
+| 116 | `SEND_NEXT_DEVICE_CONFIG` | D | Advance device-key cursor; names, not values. [Details](PROTOCOL_CONFIGURATION.md) |
+| 117 | `START_FF_KEY_EXCHANGE` | D | Reset feature-key enumeration and return count. [Details](PROTOCOL_CONFIGURATION.md) |
+| 118 | `SEND_NEXT_FF` | D | Advance feature-key cursor; names, not values. [Details](PROTOCOL_CONFIGURATION.md) |
+| 119 | `SET_DEVICE_CONFIG_VALUE` | D | Typed named device-configuration SET. [Details](PROTOCOL_CONFIGURATION.md) |
+| 120 | `SET_FF_VALUE` | D | Typed named feature-flag SET. [Details](PROTOCOL_CONFIGURATION.md) |
+| 121 | `GET_DEVICE_CONFIG_VALUE` | D | Read named device configuration from storage. [Details](PROTOCOL_CONFIGURATION.md) |
+| 122 | `STOP_HAPTICS` | D | Revision 1 only; asynchronous pending/final stop, one-byte body. [Details](PROTOCOL_ALARMS.md#busy-execution-and-stop-completion) |
+| 123 | `SELECT_WRIST` | D | ECG wrist: revision 1, right 1 / left 2; persistence unproved. [Details](PROTOCOL_ECG.md) |
+| 124 | `TOGGLE_LABRADOR_DATA_GENERATION` | D | ECG processing: revision 1, stop 1 / start 2 or 3; hardware guarded. [Details](PROTOCOL_ECG.md) |
+| 125 | `TOGGLE_LABRADOR_RAW_SAVE` | D | Boolean raw-ECG saving, independent of live transport. [Details](PROTOCOL_ECG.md) |
+| 126 | `Send raw ECG` | D | Boolean raw-ECG live transport. [Details](PROTOCOL_ECG.md) |
+| 127 | `Save filtered ECG` | D | Boolean filtered-ECG saving. [Details](PROTOCOL_ECG.md) |
+| 128 | `GET_FF_VALUE` | D | Read named feature configuration from storage. [Details](PROTOCOL_CONFIGURATION.md) |
+| 129 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 130 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 131 | `SET_RESEARCH_PACKET` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 132 | `GET_RESEARCH_PACKET` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) |
+| 133 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 134 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 135 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 136 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 137 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) |
+| 138 | `Set signal-processing configuration` | D | Revision 1 plus selector; 0–8 presets, 9–255 acknowledge without selecting. [Details](#ordinary-service-commands) |
+| 139 | `TOGGLE_LABRADOR_FILTERED` | D | Boolean filtered-ECG live transport. [Details](PROTOCOL_ECG.md) |
+| 140 | `SET_ADVERTISING_NAME` | D | Revision 1; advertising name at most 15 bytes, update requested after storage. [Details](#ordinary-service-commands) |
+| 141 | `GET_ADVERTISING_NAME` | D | Revision 1; fixed 19-byte name response. [Details](#ordinary-service-commands) |
+| 142 | `START_FIRMWARE_LOAD_NEW` | D | Begin executable-image transfer; persistent mutation. [Details](#service-and-sensitive-operations) |
+| 143 | `LOAD_FIRMWARE_DATA_NEW` | D | Write bounded executable-image chunk; persistent mutation. [Details](#service-and-sensitive-operations) |
+| 144 | `PROCESS_FIRMWARE_IMAGE_NEW` | D | Process transferred image; later validation/activation incomplete. [Details](#service-and-sensitive-operations) |
+| 145 | `GET_HELLO` | D | Revision 1/3 Hello; pending then asynchronous identity response. [Details](#core-command-contracts) |
+| 146 | `SET_CLOCK` | D | Revision-1 seconds/ticks clock SET; hundredths precision. [Details](#core-command-contracts) |
+| 147 | `GET_CLOCK` | D | Revision-1 seconds/ticks clock GET; zero-time caveat. [Details](#core-command-contracts) |
+| 148 | `Wear-detection override` | D | Revision 1; 1 forces worn and disables detection, 0 restores detection. [Details](#ordinary-service-commands) |
+| 149 | `Set LED accessibility` | D | Revision 1 boolean; persistent LED accessibility option. [Details](#ordinary-service-commands) |
+| 150 | `Set gyro mode (Disable Gyro)` | D | Revision-1 gyro SET: 0 disabled, 1 enabled; partial failure possible. [Details](PROTOCOL_CONFIGURATION.md) |
+| 151 | `GET_BATTERY_PACK_INFO` | D | Revision 1; cached 28-byte pack body, presence and freshness distinct. [Details](PROTOCOL_TRANSPORT.md#battery-pack--command-151) |
+| 152 | `Get gyro mode status` | D | Revision-1 cached gyro-mode predicate; not fresh sensor read. [Details](PROTOCOL_CONFIGURATION.md) |
+| 153 | `TOGGLE_PERSISTENT_R20` | D | Persistent optical/R20 collection contribution. [Details](PROTOCOL_CONFIGURATION.md) |
+| 154 | `TOGGLE_PERSISTENT_R21` | D | Persistent IMU/R21 collection contribution. [Details](PROTOCOL_CONFIGURATION.md) |
+| 155 | `START_CERTIFICATE_TRANSFER` | D | Begin certificate transfer; security-state mutation. [Details](#service-and-sensitive-operations) |
+| 156 | `LOAD_CERTIFICATE` | D | Load certificate data; security-state mutation. [Details](#service-and-sensitive-operations) |
+| 157 | `VERIFY_CERTIFICATE` | D | Verify transferred certificate, identity and freshness; detailed validation limits documented. [Details](#service-and-sensitive-operations) |
+| 158 | `PROCESS_CERTIFICATE` | D | Process/store certificate material; security-state mutation. [Details](#service-and-sensitive-operations) |
+| 159 | `LOCK_DEVICE` | D | Revision-1 queued authorization lock; conditional certificate clearing and reauthorization. [Details](#service-and-sensitive-operations) |
+
+## Unsupported and cross-version commands
+
+The U classification is version/context specific. For example, REPORT_VERSION_INFO, SEND_R10_R11_REALTIME and GET_EXTENDED_BATTERY_INFO have older family meanings or partial observations; that does not override their unsupported status here. The legacy image-transfer, analog-setting and advertising-name families likewise must not be used as aliases for supported high-number commands. An unsupported reply is distinct from a timeout, malformed-frame rejection or known command returning failure. Neither ID adjacency nor a familiar enum name is a basis for probing a replacement.
+
+## Core command contracts
+
+| Operation | Request and response | Effect / limits |
+|---|---|---|
+| Link check | No semantic payload fields; success, fixed 13-byte NUL-terminated acknowledgement. | Link-level acknowledgement, not device identity. |
+| Live HR | Historical NOOP request byte `0` off / `1` on. | Live HR delivery is distinct from the command response; current acceptance and complete prerequisites unresolved. |
+| Generic HR profile | One byte `0`/`1`; others fail. Success or failure with empty body, according to setting-write result. | Updates a nonvolatile policy; downstream standard-GATT behavior and observed restart survival unresolved. |
+| Event delivery | One byte `0`/`1`; others fail; accepted request returns success with empty body. | Delivery toggle. Whether it selects historical events, future events or both remains unresolved; “flush stored events” is not established. |
+| High-frequency sync entry | Revision 2, period `u16le`, duration `u16le`; period strictly greater than 60, duration strictly less than 28,800. | Accepted request returns success empty; invalid values fail empty. Duration is seconds; counter threshold is twice the period. [Scheduler contract](#high-frequency-sync-scheduler). |
+| High-frequency sync exit | No semantic payload fields; success empty precedes queued disable. | Explicit exit clears active and emits event 98; automatic expiry differs. [Scheduler contract](#high-frequency-sync-scheduler). |
+| History request / abort | Historical NOOP uses explicit `00` for each operation. | Start delivers metadata/records asynchronously; abort is not trim. Command 22 returns state plus two zero bytes; states 6/7/9/10 fail and others succeed, while asynchronous work is still requested. Delivery is separate. |
+| History acknowledgement | `01` plus the eight original HISTORY_END bytes, only after local commit. | May release stored device history. See [storage ownership](PROTOCOL_TRANSPORT.md#history-sequencing-and-storage-ownership). |
+| Range | No semantic request fields; initial pending empty. | Final body is 65 bytes with page cursors, estimates and clock pairs; see [range fields](PROTOCOL_TRANSPORT.md#data-range--command-34). An earlier MG returned pending then success; do not generalize all older offsets. |
+| Battery | Historical NOOP uses empty or `00`; current query is asynchronous with no immediate reply established. | Older WHOOP 4 charge is `u16le / 10` percent; an older WHOOP 5 observation identified the first body byte as whole percent without establishing a universal one-byte body. The current final body is u32 whole percent; zero can be a fallback. Only a nonzero error on the ordinary completion callback carries four zero bytes; actual timeout/error handling does not guarantee that reply (see [battery responses](PROTOCOL_TRANSPORT.md#battery-level--command-26)). |
+| Deprecated clock SET/GET | Historical SET: seconds `u32le` plus four zero subsecond bytes; WHOOP 4 also has a ninth zero variant. GET uses empty or `00`. | Current payload/reply unresolved. Separate from revision-1 high-number clocks. |
+| Legacy Hello | WHOOP 4 client uses `00`. | The current local handler builds no command reply; do not expose identity fields unnecessarily. |
+| New Hello and clock pair | [Exact revision, size and clock precision contracts](PROTOCOL_TRANSPORT.md#clock-and-identity-contracts). | Hello final bodies are 107/111 bytes; retain their preparation flags; clock success alone does not prove nonzero valid time. |
+
+## High-frequency sync scheduler
+
+
+Command 96 revision 2 carries revision `2`, period u16le at byte 1 and duration
+u16le at byte 3. Accepted values are period >60 and duration <28800. Duration is
+in seconds, compared against wall-clock seconds on a later scheduler callback.
+The period controls a callback-count threshold: **2 × period callbacks**. Each
+callback is nominally about half a second, making period units approximately
+seconds. Period 61 represents 122 received callbacks, nominally about 61 seconds
+from a zero counter. Timer restart, interrupt and scheduler latency remain
+separate; this is not an exact elapsed-time guarantee.
+Entering while already active does not replace the period, duration or start
+time. Do not treat its successful response as confirmation that a session was
+refreshed.
+
+First entry emits event 97 (`0x61`). While active, a 16-bit counter advances
+once per callback; reaching twice the period emits event 96 (`0x60`) and resets
+the counter. For periods 32768–65535, twice the period exceeds the counter's
+maximum, so that periodic event cannot be reached by this comparison. Entry
+and explicit exit do not reset the counter in these paths, so its existing
+value may affect the first interval.
+
+Command 97 emits event 98 (`0x62`) and clears active. Automatic duration expiry
+clears active without that exit event. Duration zero expires on the next
+callback, after the periodic-event check. Wall-clock changes and 32-bit deadline
+arithmetic matter; this is not a monotonic elapsed timer. These paths schedule
+event notifications; they do not establish faster Bluetooth transfer or a
+changed acquisition rate. Further event consumers or client reactions are
+outside this contract. Keep command IDs and event IDs in separate namespaces.
+
+
+## Haptics and alarms
+
+Notification haptics uses a revision-1, 12-byte body: revision at 0, eight waveform-effect bytes at 1–8, effect loop-control `u16le` at 9–10, overall-repeat byte at 11. Existing NOOP patterns use effect-loop control zero. The overall field counts repetitions **after the first pulse**, so a request for N pulses uses N−1. Older MG validation found four buzzes when that byte was 3. NOOP bounds requests to one through eight pulses; this client bound is not a universal firmware maximum. The current WHOOP 5/MG shared pattern validator requires each of the eight effect bytes to be at most 251 and the overall-repeat byte to be below 8. It does not validate the loop-control field. Passing these checks alone does not establish a valid physical waveform. The existing effect sequence and its attribution remain in the NOOP implementation; these fields do not establish every possible waveform ID.
+
+Alarm SET/GET, validation, readback, single/all-ID disable and manual RUN are described in
+[alarm configuration and execution](PROTOCOL_ALARMS.md). The current record is 21 bytes,
+including crescendo; the earlier NOOP 20-byte encoder obtains crescendo zero from framing
+padding and is not thereby shown to send a short frame. SET validation detail, storage
+result, execution event and physical wake are separate outcomes. RUN consumes its selected
+saved schedule and is not a guaranteed nondestructive preview.
+
+STOP_HAPTICS takes revision 1 alone and returns pending then a final result, each with body `[1]`. Stopping a buzz does not prove that a scheduled alarm was removed.
+Older alarm arming acknowledgements remain scoped to their runs; no successful physical
+wake is claimed. Haptic actions belong to deliberate app actions, not connection
+discovery.
+
+## Service and sensitive operations
+
+Pairing reset and reboot interrupt connection and work; preservation is not established for every operation. Forced trim and read-pointer changes mutate history ownership and cannot substitute for committed-chunk acknowledgement. Their recovery behavior remains unresolved. Battery-pack fields are in [transport](PROTOCOL_TRANSPORT.md#battery-pack--command-151).
+
+The following operation-specific schemas are for independent interface implementations. A known field does not establish every prerequisite, safe operating sequence or completed effect. These fields do not authorize or describe a tested device update.
+
+All service-table offsets are command-body offsets; integers are little-endian. Revisioned operations use revision 1. Outer result is 1 success / 0 failure, separate from the listed body. Semantic lengths do not establish acceptance of unpadded short frames.
+
+## Ordinary service commands
+
+| Command | Request body | Response body | Behavior |
+|---:|---|---|---|
+| 148 | `revision:u8=1, override:u8` | `[1]` | `override=1` forces the worn state and disables normal wear detection. `0` restores normal detection. Other values fail. Restoring detection does not promise an immediate off-body report. |
+| 149 | `revision:u8=1, enabled:u8` | `[1]` | Sets the persistent LED accessibility option; accepts only 0 and 1. Success reports the settings write result. Exact visual patterns depend on the indicator state. |
+| 140 | `revision:u8=1, length:u8, name[length]` | `[1]` | Stores an advertising name of 0–15 bytes and requests an advertising update. Length counts bytes. No character-set validation is established. Radio visibility and client cache refresh may lag the reply. |
+| 141 | `revision:u8=1` | Fixed 19 bytes, described below | Reads the advertising name. |
+| 84 | `revision:u8=1` | `[1, 0, 255, on_body]` | Returns cached on-body status, 0 or 1. The two middle bytes are fixed placeholders, not measured location or confidence. |
+| 138 | `revision:u8=1, selector:u8` | Empty | Selectors 0–8 select defined presets whose meanings remain undocumented. Every byte value receives success for revision 1; values 9–255 do not select a new preset. Success does not confirm persistence. |
+| 103 | `revision:u8=1` | `[1]` | Acknowledges a BLE UART disable request before its asynchronous handling. No enable argument or completion/readback contract is established. |
+| 32 | No command fields established | Empty | Acknowledges a power-cycle request before asynchronous lifecycle handling. Completion delay, power-rail behavior and retained state are not established. |
+
+For the revisioned commands, an unsupported revision fails. Command 138 has an empty failure body. Commands 84 and 141 return their initialized fixed-size bodies on revision failure: `[1,0,0,0]` and `[1]` followed by 18 zeros. The remaining one-byte ordinary responses retain `[1]` on failure.
+
+Command 149 updates one option within a shared stored settings record. If reading that record fails, it starts from defaults before writing the requested option. Success therefore does not prove that the other previously stored options were preserved.
+
+Command 141 returns revision 1 at offset 0; source/status at offset 1; a NUL-inclusive length of 1–16 at offset 2; and 16 bytes of name storage at offsets 3–18. Status 1 identifies a custom name. Status 2 covers fallback or empty storage and storage-read failure; it does not diagnose a specific storage error. The last storage byte is zero. Limit decoding to this fixed storage and exclude the terminator from display.
+
+## Image-transfer command boundaries
+
+See [image transfer](PROTOCOL_UPDATES.md#image-transfer-command-boundaries).
+
+## Certificate command boundaries
+
+See [certificates and authorization](PROTOCOL_UPDATES.md#certificate-command-boundaries).
diff --git a/docs/PROTOCOL_CONCEPTS.md b/docs/PROTOCOL_CONCEPTS.md
new file mode 100644
index 0000000000..f4755f2e79
--- /dev/null
+++ b/docs/PROTOCOL_CONCEPTS.md
@@ -0,0 +1,51 @@
+# Shared protocol concepts
+
+Read the [scope and compatibility](PROTOCOL.md#scope-and-compatibility) before applying this page.
+
+## Standard SIG services (both generations)
+
+| Service | UUID | Characteristic | UUID | Notes |
+|---------|------|----------------|------|-------|
+| Heart Rate | `180D` | HR Measurement | `2A37` | HR + R-R; works **unbonded** |
+| Battery | `180F` | Battery Level | `2A19` | single byte = battery percent |
+
+Heart Rate Measurement uses a flag byte, 8- or 16-bit HR, optional Energy Expended
+and R-R intervals in units of 1/1024 second. Battery Level is a single-byte percent.
+These standard characteristics are separate from the custom command replies.
+
+---
+
+
+
+## Frame envelope
+
+A frame is a self-delimiting byte string beginning with a Start-Of-Frame marker and ending with
+a CRC32 trailer. The two generations share the CRC32 payload check but differ in the header
+checksum. Select the family before parsing:
+
+| Family | Header check | Enum (`HeaderCRCKind`) |
+|--------|--------------|------------------------|
+| `whoop4` | CRC8 (poly `0x07`) | `.crc8` |
+| `whoop5` | CRC16-Modbus (poly `0xA001`, init `0xFFFF`, reflected) | `.crc16Modbus` |
+
+
+
+## Checksums
+
+| Algorithm | Function | Parameters |
+|-----------|----------|------------|
+| CRC8 | `crc8(_:)` | table-driven, poly `0x07`, init `0x00` |
+| CRC32 (zlib) | `crc32(_:)` | reflected, poly `0xEDB88320`, init `0xFFFFFFFF`, final XOR `0xFFFFFFFF` |
+| CRC16-Modbus | `crc16Modbus(_:)` | poly `0xA001`, init `0xFFFF`, reflected |
+
+Validate the complete frame before decoding or updating state. CRC checks detect
+corruption; they are not cryptographic authentication. Generation-specific length
+and header rules are defined in the profiles.
+
+## Requests, results and delivery
+
+A BLE write acknowledgement, a command result, completion of asynchronous work and delivery of a measurement are separate events. Correlate replies within the connection and command context; do not interpret the first command-body byte as a universal success flag. Byte offsets belong to the selected [WHOOP 4](PROTOCOL_WHOOP4.md) or [WHOOP 5/MG](PROTOCOL_WHOOP5.md) profile.
+
+## History and durable ownership
+
+Historical transfer uses START, chunk END and COMPLETE concepts. Save the data needed by the application durably before acknowledging its corresponding chunk. Preserve the received acknowledgement token rather than reconstructing it from assumed timestamps. This client invariant does not promise exactly-once delivery, physical erasure or power-loss durability. The [WHOOP 5/MG transport](PROTOCOL_TRANSPORT.md#history-sequencing-and-storage-ownership) specifies its boundary conditions; the [WHOOP 4 profile](PROTOCOL_WHOOP4.md#72-history_end-payload-layout) retains the older token layout.
diff --git a/docs/PROTOCOL_CONFIGURATION.md b/docs/PROTOCOL_CONFIGURATION.md
new file mode 100644
index 0000000000..e811e4733f
--- /dev/null
+++ b/docs/PROTOCOL_CONFIGURATION.md
@@ -0,0 +1,340 @@
+# WHOOP configuration and collection controls
+
+Applicability: [central scope and compatibility](PROTOCOL.md#scope-and-compatibility).
+
+This is the configuration companion to [the protocol reference](PROTOCOL.md). Unless explicitly labeled historical, the contracts below apply to the reference baseline. A stored value, its interpreted policy, accepted request, effective sensor activity and delivered packet stream are different states. Do not collapse them into one “enabled” boolean. Device-specific factory values, power-interruption survival and all hardware variants are not established by these contracts.
+
+## Named configuration interface
+
+| Operation | Command | Request / response boundary |
+|---|---|---|
+| Count device keys | 115 | Revision 1; resets the enumeration cursor and returns a one-byte count after revision 1 (two body bytes). |
+| Next device key | 116 | Revision 1; advances a shared enumeration cursor, not a caller-supplied index. |
+| Count feature keys | 117 | Revision 1; resets the feature enumeration cursor and returns a one-byte count after revision 1 (two body bytes). |
+| Next feature key | 118 | Revision 1; advances the feature enumeration cursor. |
+| Set device value | 119 | Revision 1, 32-byte key field, 32-byte value field. |
+| Set feature value | 120 | Same layout, feature namespace. |
+| Read device value | 121 | Revision 1 followed by a 32-byte key field; 65-byte reply. |
+| Read feature value | 128 | Same request and reply sizes, feature namespace. |
+
+The current named SET body is **65 bytes** before outer framing/padding. Fields are NUL-terminated; names have at most 31 meaningful bytes in the 32-byte field. Use ASCII values matching the descriptor type below and zero-fill unused field bytes. The shorter historical NOOP encoder, with one value character and a few padding bytes, does not establish the complete request contract. Handling of truncated SET bodies is unresolved; do not use them as capability probes.
+
+Enumeration replies expose revision, index, validity and a bounded name; they do not return values. A false validity byte alone is not a universal terminator. Earlier NOOP handling recognizes index 255 as terminal and uses bounded count/slack and empty-response limits. The baseline count is one byte; older formats remain separately scoped. NOOP's safeguards—128 responses maximum, eight consecutive empty entries, and announced count plus four—are client limits, not promised firmware capacities. Serialize enumeration requests because the cursor is stateful.
+
+GET reads configuration storage rather than merely echoing the preceding SET. The current reply is the 65-byte key/value record specified below, including its failure bodies. Validate response command, origin, result, bounds and returned key before using a value. Preserve the bounded value bytes instead of taking the final padded byte as a boolean. A response timeout is not evidence that a named key or command does not exist.
+
+### Value types and storage
+
+| Type | Accepted representation / conversion | Meaning boundary |
+|---|---|---|
+| Tri-state | Exact NUL-terminated ASCII `0`, `1` or `2` | Numeric policy values, not one universal on/off encoding. |
+| Unsigned byte | Decimal integer bounded above by 255 | The parser limit is not a per-key safe operating range. |
+| Tenths | Nonnegative decimal value, scaled by 10 with `+0.5` rounding into bounded integer storage; readback decodes a 16-bit quantity | `max_collection_backlog` uses tenths. It expresses capacity percentage in tenths of a percentage point; safe operating thresholds remain unknown. |
+
+Device and feature namespaces are stored separately in checked nonvolatile records. A named SET rewrites the namespace's five records; atomicity of those writes is not established. A failed record read substitutes zero-filled data. Thus zero readback may be a fallback, not a successfully recovered saved value, and neither proves a factory default. Per-key callback effects and reboot behavior are not uniformly established. Read back a changed value when the relevant reply contract is supported, and separately observe the intended application effect.
+
+## Device key inventory
+
+All eight names below are eligible for named lookup. Eligibility does not establish a complete behavioral contract or support on every device.
+
+| Key | Value type | Known behavior / remaining limits |
+|---|---|---|
+| `sigproc_wear_detect` | Tri-state | Wear-detection setting; per-value effect, applied default and safe change conditions unresolved. |
+| `enable_rfid` | Tri-state | RFID setting; exact effect, polarity and default unresolved. |
+| `max_collection_backlog` | Tenths | Capacity percentage in tenths; strict threshold exceed attempts to clear continuous mode. Safe operating thresholds remain unresolved. |
+| `cont_collection_mode` | Unsigned byte | Contributes to optical/IMU collection policy alongside session and persistent requests. Mode 0 removes continuous collection; 1 requests optical and IMU collection. Other accepted bytes are not established supported modes. |
+| `whoop_live_hr_in_adv_ind_pkt` | Tri-state | Stored 1 selects this advertising preference before the two-HRM preference; 0/2 do not. Exact advertised contents and timing remain unresolved. |
+| `whoop_live_2_hrm_devices` | Tri-state | Stored 1 selects the named preference; 0/2 do not. Exact connection capacity and factory state remain unresolved. |
+| `enable_raw_data_w_ecg` | Tri-state | Resolver: `0` and `1` true, `2` false; fallback is true. Requests companion raw optical/IMU handling after successful ECG startup. It is **not** the ECG master gate. |
+| `dorset_detection_period_min` | Unsigned byte | Zero or a failed read selects fallback 5; a nonzero value uses the stored byte. The name does not independently establish time units or a safe range. |
+
+In particular, writing zero is not a universal reset-to-off operation. The ECG companion resolver defaults on even with zero-filled storage. See [ECG behavior](PROTOCOL_ECG.md) for its startup and independent live/save controls.
+
+## Feature flag inventory
+
+All 25 descriptors use the tri-state representation. Twenty are eligible for named lookup; five are not. Ineligible names are included so an application does not mistake a readable name or historical write bundle for a supported named SET. They may still have an internal role. For entries without a decoded consumer below, polarity, applied default, packet effect and hardware variation remain **unknown**, regardless of how suggestive the name is.
+
+| Feature key | Named lookup | Known meaning / limit |
+|---|---|---|
+| `general_ab_test` | Ineligible | No public operational value mapping established. |
+| `enable_r22_packets` | Eligible | Historical packet 47/layout 22 master: `1` permits, `0` and `2` do not. Internal variants/readiness remain separate. |
+| `enable_r22_v2_packets` | Eligible | Consumer maps 1 to true, 0/2 to false; see [version selection](#r22-version-preferences). |
+| `enable_r22_v3_packets` | Eligible | Consumer maps 1 to true, 0/2 to false; see [version selection](#r22-version-preferences). |
+| `enable_r22_v4_packets` | Eligible | Consumer maps 1 to true, 0/2 to false; see [version selection](#r22-version-preferences). |
+| `enable_r22_v5_packets` | Eligible | Consumer maps 1 to true, 0/2 to false; see [version selection](#r22-version-preferences). |
+| `enable_r22_v6_packets` | Eligible | Consumer maps 1 to true, 0/2 to false; see [version selection](#r22-version-preferences). |
+| `enable_r22_v8_packets` | Eligible | Per-version selector semantics unresolved. |
+| `enable_r22_v9_packets` | Eligible | Consumer maps 1 to true, 0/2 to false; see [version selection](#r22-version-preferences). |
+| `make_hrfm_visible` | Eligible | Consumer semantics unresolved. |
+| `disable_pip_r26_packets` | Eligible | Inverse historical packet 47/layout 26 permission: `1` removes it; `0` and `2` permit it. Producer readiness is additionally required; this is not a global optical stop. |
+| `wear_detect_bias` | Eligible | Consumer semantics unresolved. |
+| `enable_pdaf_walk_det` | Ineligible | No named SET support established. |
+| `enable_maverick_model` | Ineligible | No named SET support established. |
+| `hr_ch_switching` | Eligible | Enables one prerequisite of the alternate-source branch reflected in R18 bits 4/5; other input and state conditions also apply. See [source selection](PROTOCOL_SENSORS.md#r18-quality-adjacent-source-selection-bits). |
+| `ir_hw_switching` | Eligible | Consumer semantics unresolved. |
+| `enable_passive_strap_fit_gen5` | Eligible | Consumer semantics unresolved. |
+| `enable_sig11_during_sleep` | Ineligible | Historical bundle inclusion does not establish named SET support here. |
+| `dorset_inhibit_wpt` | Eligible | Consumer semantics unresolved. |
+| `enable_sig12` | Ineligible | Historical bundle inclusion does not establish named SET support here. |
+| `enable_frizzle_burst_mode` | Eligible | Consumer semantics unresolved. |
+| `ir_1x_enable` | Eligible | Consumer semantics unresolved. |
+| `enable_rocky_again` | Eligible | A change can trigger processing reset/reinitialization during configuration refresh; algorithm and physical effects remain unresolved. |
+| `project_drawbridge` | Eligible | Stored 1 enables one side of a conditional gate that can skip normal buffered submission and recall deferred work. Another state condition is required; affected record types remain unresolved. Not a general stream-off or privacy control. |
+| `wear_detect_fast_event` | Eligible | Consumer semantics unresolved. |
+
+The historical write of ASCII `2` to the R22 master must not be described as universally enabling R22. In this version it removes that master contribution. Likewise the inverse-named PIP flag must not be presented with the same permission polarity. Consumer truth does not establish emitted variants, and v8 remains outside this mapping; do not guess an all-features bundle. Preserve unknown record variants instead of forcing them through a known decoder.
+
+## Collection, storage and live transport
+
+Requests below are command bodies, excluding the transport envelope. Revision-1 boolean controls take `[1, state]` with `state` 0 or 1; other boolean values are rejected. Live transport and persistent settings are separate from shared session requests; overlapping writers require the ordering rules below.
+
+| Control | Commands | Contract and application effect |
+|---|---|---|
+| Raw producer start/stop | 81 / 82 | Revision 1 supported; start also has a revision-2 path whose body remains unresolved. Start/stop is separate from live transport and saving. |
+| IMU session saving | 105 | Revision-1 boolean; changes a session collection contribution in RAM. It does not write the persistent policy below. |
+| IMU live transport | 106 | Revision-1 boolean; controls live IMU delivery independently of saved records. |
+| Optical session saving | 107 | Revision-1 boolean; separate RAM collection contribution. |
+| Optical live transport | 108 | Revision-1 boolean; independently controls live optical delivery. |
+| Persistent optical/R20 policy | 153 | Revision-1 boolean. Wire 0 stores explicit off, wire 1 stores on. Nonvolatile write requested, but ACK does not check programming success. |
+| Persistent IMU/R21 policy | 154 | Same persistent policy contract, independent of IMU session saving. |
+
+The two dedicated persistent policies share an option record with LED accessibility (149). If the setter cannot read that record, it writes from defaults before applying the requested option, so other stored options may be replaced. This is separate from the named configuration namespaces below.
+
+The persistent policy resolver treats stored 1 as true and stored 0/2 as false. A missing or invalid record falls back to stored zero; a deployed device may already contain other values. There is no established BLE getter for these two dedicated policies. The durable-write path is distinct from RAM session flags, but observed reboot survival, power-loss atomicity and actual successful programming are not guaranteed by a command acknowledgement.
+
+Raw, individual-sensor and ECG companion controls write shared session requests in event order; they are not independent leases. A later raw stop can clear requests set by another session control. Persistent and continuous sources remain separate contributions. Full downstream hardware application is unresolved; “off acknowledged” is not proof that the sensor is idle. Live transport, saving and producer start/stop each need their own application state. Startup/reset defaults and disconnect behavior are not universally known.
+
+The earlier live-IMU sequence starts raw production before enabling live IMU transport; stopping production and disabling that transport are separate cleanup operations. Its 1,244-byte frame contains 100 six-axis samples. This is a versioned example, not a frame size to hard-code for every record. See [sensor layouts](PROTOCOL_SENSORS.md) and [raw capture operations](RAW_DATA_CAPTURE.md).
+
+## Other sensor configuration
+
+### AFE parameters (61/62)
+
+
+The request and response structure is 12 bytes, with **no revision prefix**:
+
+| Offset | Width | Field |
+|---|---|---|
+| 0 | 4 | Channel word, little-endian |
+| 4 | 4 | Setting word, little-endian |
+| 8 | 4 | Value bits, little-endian: SET input / GET output |
+
+Preserve the complete 32-bit value. Signed display is not proof of a common
+physical unit or range: individual settings may normalize nonzero to 1,
+truncate to a byte, or run their own validation/conversion. Both commands are
+gated by subsystem state. Check the response result before interpreting its
+body as readback; failed responses can retain the input words. Successful
+readback describes the cached configuration, not proof of analog register
+application or reboot persistence. SET queues configuration processing after
+dispatch even when dispatch reports an error; its response-refresh read is not
+independently checked.
+
+For generic per-channel settings, canonical channel values are 1–6; lookup
+uses their low byte. Do not rely on ignored upper bits. The numeric selector
+inventory is:
+
+| Setting | Established contract |
+|---|---|
+| 1–5, 7–12 | Per-channel values with field-specific conversion/validation; physical labels, units and safe ranges unresolved |
+| 6, 22 | Unsupported dispatch |
+| 13 | Per-channel boolean; nonzero normalizes to 1 |
+| 14–19 | The same channel boolean, selecting channels 1,2,4,5,6,3 respectively regardless of the channel word |
+| 20 | Per-channel byte value; SET narrows to u8 |
+| 21, 23 | Separate global booleans; user-facing functions unresolved |
+| 24 | Low-byte selector 0 or 1; stored/readback value remains that selector; physical interpretation unresolved |
+
+Setting zero and other unhandled full-word selectors fail dispatch. These
+contracts support encoding and decoding; they do not supply a safe analog
+tuning interface or authorize guessing wavelengths, current, gain or defaults.
+
+
+### Signal-processing configuration (138)
+
+Signal-processing configuration takes revision 1 and a value byte. All byte values receive success; 0–8 select defined presets with unresolved meanings, while 9–255 do not select a new preset. Storage and reconfiguration are still attempted, and success does not prove persistence. See [service contracts](PROTOCOL_COMMANDS.md#ordinary-service-commands).
+
+### Gyro mode (150/152)
+
+Commands 150/152 use revision 1; SET takes a following boolean byte.
+
+
+Command 150 argument 0 requests gyro disabled and argument 1 requests enabled.
+The operating-mode register is written and read back before the cached mode is
+updated. FIFO reconfiguration then runs as a separate stage. Command 152
+reports whether the cached mode equals the enabled mode; it does not perform a
+fresh physical read or validate FIFO configuration.
+
+**A failed SET can already have changed the mode.** If FIFO reconfiguration
+fails after the mode write succeeded, the command reports failure while the
+cache already contains the new mode. A failure during the earlier mode stage
+can also follow a physical write whose readback could not be verified. Read
+back after failure and show uncertain application state; neither success nor
+failure is an atomic rollback guarantee, and a changed GET does not establish
+that the FIFO stage succeeded. Enable/disable events 115/116 are emitted only
+after both stages succeed.
+
+A successful initialization path requests the enabled mode, but that is not a
+guaranteed final boot state or a user's saved preference. The traced operation
+changes sensor registers and cached state. Persistence across reboot, actual
+power/sample behavior and later collection-state overrides remain separate.
+
+
+## Configuration reads — commands 121 and 128
+
+Command 121 reads device configuration; 128 reads feature flags. The request is
+revision 1 followed by a 32-byte key. Use at most 31 key bytes plus NUL padding.
+Both commands return a **65-byte body**, including failures.
+
+| Offset | Width | Field |
+|---:|---:|---|
+| 0 | 1 | Revision 1 |
+| 1 | 32 | First 31 requested key bytes followed by NUL |
+| 33 | 32 | Formatted value, terminated within the field |
+
+On SUCCESS, the value is text: depending on the key's type, it can be `0`, `1`,
+`2`, an unsigned decimal integer, or an unsigned decimal with one fractional
+digit. No binary value or type tag is present. Key-specific meaning and units
+must come from the key's schema.
+
+Unknown keys, reported value-read helper errors and formatting failures return
+result 0, the normalized key echo and an all-zero value field. An underlying
+checked-storage-slot read failure is different: the loader substitutes zero bytes
+and continues successfully. A valid key can therefore return result 1 with a
+formatted zero fallback despite such a storage failure. Unsupported request revisions
+return result 0 with revision 1 and 64 zero bytes, without a key echo. Accept a
+value only after checking result 1 and matching the canonical key. A non-NUL
+32-byte key is normalized and therefore will not be echoed exactly.
+
+Enumeration-start commands 115 and 117 use revision 1. Their successful body is
+two bytes: revision 1 and a **one-byte entry count**. Invalid revision returns
+FAILURE with `01 00`. They reset separate enumeration cursors.
+
+## Collection settings and overlapping controls
+
+`cont_collection_mode=0` removes the continuous collection
+request; `1` requests both optical and IMU collection. Other nonzero byte values contribute an optical request without the mode-1 IMU
+request and can enter an IMU diagnostic path. They
+are not established clean operating modes and should not be exposed as supported
+choices merely because storage accepts them.
+
+`max_collection_backlog` expresses a percentage of collection capacity, in tenths
+of a percentage point. When a continuous collection check observes a percentage
+strictly above the configured threshold, it attempts to store continuous mode
+zero. Equality does not trigger that action. A zero threshold can therefore end
+continuous collection after backlog becomes nonzero. This does not establish
+whether the storage attempt succeeded or when another collection source stops.
+Entering or changing to continuous policy can be deferred when backlog is at or
+above 0.2%, retaining the previous policy and emitting event 124 with transition
+value 4 and reason 3. This is separate from the configurable maximum threshold.
+
+Raw collection, individual optical/IMU session controls and ECG companion raw
+collection overlap. They update shared session requests rather than independent
+ownership counts. A later raw stop can clear session requests previously set by
+ECG companion start or an individual sensor control. Persistent and continuous
+requests can still contribute. Serialize overlapping operations, retain the
+intended application state, and verify the resulting data flow separately from
+command acknowledgments.
+
+Advertising preferences also overlap: the standard heart-rate advertising
+preference is considered before the two-HRM-device preference. In these preference
+checks, stored 1 selects the named branch; 0 and 2 do not. This does not establish
+a guaranteed number of simultaneous client connections.
+
+For the R22 master and selectors v2/v3/v4/v5/v6/v9, the configuration consumer
+maps stored 1 to true and 2 to false, with zero selecting false. These values do
+not prove any particular packet was emitted. The v8 selector is outside this
+verified consumer mapping. The inverse-named PIP flag must retain its separately
+documented permission inversion. Do not substitute historical write recipes for
+version-specific readback and data-flow checks.
+
+For the described processing consumers, `wear_detect_bias`, `hr_ch_switching`,
+`ir_hw_switching`, `enable_frizzle_burst_mode`, `ir_1x_enable`,
+`enable_rocky_again` and `wear_detect_fast_event` resolve stored 1 as true and
+0/2 or read failure as false. This identifies consumer fallback, not the installed
+default or the physiological effect. Changes in `disable_pip_r26_packets` or
+`enable_rocky_again` can trigger processing reset/reinitialization during
+configuration refresh; an accepted settings write need not leave intermediate
+processing state untouched.
+
+A stored zero is a consumer fallback, not a factory reset. No factory value,
+reconnect survival or reboot outcome is established by these additions.
+
+
+## Analog configuration readback
+
+Commands 61 and 62 carry three little-endian u32 words: channel, setting and value.
+Successful readback describes cached configuration. It does not confirm that
+all physical settings have already been applied.
+
+Settings have distinct numeric behavior:
+
+| Setting | Input and readback behavior |
+|---|---|
+| 1 | Preserves the full input word. |
+| 2 | Stores an input contribution; readback returns that contribution plus setting 3, modulo 2^32. |
+| 3 | Preserves its input and also changes setting 2 readback through the sum above. |
+| 4, 5 and 7, 8 | Each paired selector's low byte must be 1..4. Validation uses both current selectors. Successful readback preserves the assigned full word. |
+| 9, 10 | Inputs 0..5 become 0; 6..11 become 8; 12..23 become 16; all larger u32 values become 32, including values above 64. |
+| 11, 12 | Inputs 0..3999 become 0; 4000..11999 become 8000; 12000..19999 become 16000; 20000..48000 become 24000. Larger unsigned inputs fail without replacing the previous field. |
+
+For example, setting 2 input 100 with setting 3 equal to 20 reads back as 120.
+Changing setting 3 to 30 changes setting 2 readback to 130. These are numeric
+interface rules; physical units and safe tuning values are not established.
+
+Configuration application is deferred and can stop after earlier driver
+operations have run. Its pending indicator is cleared before application, so a
+cleared indicator does not guarantee success or an automatic retry. Treat
+command acceptance, cached readback and complete physical application as separate
+states. Preserve uncertainty after an application failure.
+
+
+## Collection and live-stream coordination
+
+Requested policy selection prioritizes a true persistent preference, then the
+shared raw request, then the individual sensor session request, and finally
+continuous collection. Contributor counting, active-state transitions and backlog
+guards are separate; selected policy does not prove completed physical acquisition.
+
+Raw collection can hold a shared collection request in addition to the individual
+optical and motion requests. Turning off an individual request, or stopping ECG
+companion collection, therefore does not necessarily stop a running raw session.
+Raw stop and raw-session expiry clear the shared raw request and the overlapping
+session requests. Persistent collection preferences can still keep collection
+requested afterward. Treat the operations as overlapping mutable controls and
+reconcile their resulting state.
+
+Live motion output has separate requested and active states. A requested change
+is applied later through a driver operation. Failure can leave the previous
+active output state in place. Rapidly sending an enable followed by a disable
+before application can also leave output enabled despite the last requested
+value being disabled. Serialize opposite changes and check actual output; an
+acknowledgment or requested-state readback alone does not confirm application.
+
+Cold sensor initialization clears the temporary collection and live-stream
+requests in this version. That does not establish which reset
+operations execute initialization, whether persistent preferences are reasserted,
+or what remains active after a radio disconnect. Loss of notifications is not
+evidence that sensing or historical recording stopped. Explicitly reconcile
+collection and ECG state after reconnecting.
+
+`enable_r22_packets` gates historical packet 47/layout 22. The inverse
+`disable_pip_r26_packets` flag removes historical packet 47/layout 26 publication permission; it is not a global
+optical acquisition stop. The latter path also requires producer readiness.
+Unresolved experimental settings should remain opaque: names alone do not prove
+physical effects, deployed defaults, safe values or support for additional wire
+formats.
+
+## R22 version preferences
+
+R22 preparation chooses enabled version preferences in this order:
+**9, 6, 5, 4, 3, 2, then 1 as fallback**. The master R22 flag gates preparation
+and historical publication separately. Multiple preferences select one path;
+they do not request separate output for each enabled version.
+
+Selection does not guarantee the emitted version. Version 3 falls back to 2
+when both selected 32-bit input words are zero. This is a value test, not proof
+that sensor data is absent. Version 5 falls back to 4 unless its
+readiness result is exactly 1. When version 9 selects its queued replay path,
+empty queues cause a fallback to version 4; selecting it does not guarantee fresh version-9 output. See
+[queued channels](PROTOCOL_SENSORS.md#r22-version-9-queued-channels-and-sample-encoding). Version 8 is not established in this
+selection path. Decode the [actual inner version](PROTOCOL_SENSORS.md#r22-inner-version)
+and preserve unfamiliar bodies rather than choosing a layout from settings.
diff --git a/docs/PROTOCOL_ECG.md b/docs/PROTOCOL_ECG.md
new file mode 100644
index 0000000000..e6147c1e18
--- /dev/null
+++ b/docs/PROTOCOL_ECG.md
@@ -0,0 +1,346 @@
+# WHOOP 5/MG ECG protocol
+
+Applicability: [central scope and compatibility](PROTOCOL.md#scope-and-compatibility).
+
+This chapter specifies the ECG interface applicable to the reference baseline.
+It complements [the main protocol reference](PROTOCOL.md) and separates the
+versioned sensor records from earlier generic “Labrador” payload hypotheses.
+A shared firmware version does not guarantee ECG hardware, successful
+initialization, or availability on every strap. The complete workflow and physical
+waveform calibration have not been validated on hardware for this version.
+
+All offsets below refer to the **complete reassembled format-1 frame**, starting
+at its framing byte. Check framing, declared length and both checksums before
+reading a record. Multi-byte fields are little-endian unless explicitly stated.
+
+## Commands and independent output gates
+
+Payloads start at the command revision byte, after the command envelope. Every
+command in this table requires revision `01`. Response result `1` means success;
+`0` means failure. Success reports command handling, not completed measurement.
+
+| Decimal / hex | Operation | Payload | Contract |
+|---|---|---|---|
+| 123 / 7B | Select wrist | `01 01` right; `01 02` left | Other wrist arguments fail. A valid argument can still fail when the ECG subsystem is not in the required state. |
+| 124 / 7C | Generation control | `01 01` stop; `01 02` start | Queues generation control. Argument `03` also selects the start operation; a distinct restart behavior is not established. Zero and other arguments fail. |
+| 125 / 7D | Save raw ECG | `01 00` off; `01 01` on | Independently gates raw historical output. |
+| 126 / 7E | Send raw ECG live | `01 00` off; `01 01` on | Independently gates raw live output. |
+| 127 / 7F | Save filtered ECG | `01 00` off; `01 01` on | Independently gates filtered historical output. |
+| 139 / 8B | Send filtered ECG live | `01 00` off; `01 01` on | Independently gates filtered live output. |
+
+The four output toggles accept only arguments zero and one. Generation control
+and these toggles can be rejected by a hardware compatibility guard; passing that
+guard does not satisfy every initialization prerequisite. Wrist selection has a
+separate subsystem-state condition. It updates the active in-memory selection;
+retention across reboot is **not established**. Do not encode right/left as zero/one
+on this version or promise a persistent selection.
+
+Generation and output are distinct. An enabled live gate does not start the ECG
+front end. A start ACK precedes fallible initialization and conversion setup, so it
+does not demonstrate that samples are being generated. Save controls and live
+controls do not substitute for one another.
+
+The device setting `enable_raw_data_w_ecg` controls **accompanying historical
+optical and IMU requests**, after ECG startup succeeds. It is not a master ECG
+permission. Its stored values resolve as `1 = true`, `2 = false`, and `0 = true`;
+its unavailable/read-failure fallback is also true. Successful ECG generation can
+continue when the setting is false. Stopping ECG requests companion shutdown only while its bookkeeping still records
+those requests. Repeated starts can discard that bookkeeping; see the lifecycle
+condition below. A raw session can still retain its separate shared request. This interaction matters if the app also manages
+optical/IMU collection independently: these controls write shared session requests in event order, so a later raw stop can clear ECG companion requests. Persistent and continuous sources can still contribute. A stored setting, an ACK and effective
+collection are different states.
+
+A client should select and acknowledge the intended wrist before start, choose its live/save outputs explicitly,
+request generation, and then require valid revision-specific records before
+reporting waveform reception. There is no established universally successful
+ordering that removes the wrist subsystem prerequisite. Report wrist refusal,
+start refusal, acknowledged start without data, and received records separately.
+During cleanup stop generation and disable the output gates enabled by the session.
+Do not equate silence with absent electrode contact or a completed session.
+
+## Repeated ECG start and companion collection
+
+For command 124 revision 1, both arguments 2 and 3 request ECG startup. Neither is
+an idempotent ensure-running operation. When accompanying optical/IMU collection
+was enabled by an earlier successful ECG start, another start can discard the
+bookkeeping used to turn those companion requests off. This can happen if the
+new initialization fails, or if it succeeds with the companion option now off.
+A subsequent ECG stop can then omit its usual companion-off requests.
+
+Serialize ECG session transitions and resolve the previous session before
+starting another. Reconcile shared raw/optical/IMU requests as part of the app's
+session management; those controls do not provide independent ownership leases.
+A stop response does not establish that all queued sensor changes have completed.
+This ordering condition does not establish ongoing physical acquisition or a
+particular power effect.
+
+Connection establishment does not acknowledge restored ECG or sensor sessions.
+In particular, do not implement reconnect by blindly resending ECG start.
+
+## Routing and shared header
+
+Both raw and filtered records can use either transport:
+
+| Packet type at byte 8 | Layout at byte 9 | Meaning | Full length |
+|---|---|---|---:|
+| 43 | 16 | Live raw ECG | 1,584 |
+| 43 | 17 | Live filtered ECG | 240 |
+| 47 | 16 | Historical raw ECG | 1,584 |
+| 47 | 17 | Historical filtered ECG | 240 |
+
+Type 43 alone is insufficient to choose a decoder. These records use byte 9 as
+a layout selector and byte 10 as sensor flags, **not** a command sequence and
+command number. Keep other type-43 shapes separate.
+
+| Offset | Width | Field |
+|---:|---:|---|
+| 8 | 1 | Packet type |
+| 9 | 1 | Layout 16 or 17 |
+| 10 | 1 | Common sensor flags; meaning unresolved |
+| 11 | 4 | Shared record sequence |
+| 15 | 4 | Timestamp main word |
+| 19 | 2 | Additional timestamp word |
+| 21 | 13 | Packed status and count, detailed below |
+
+Timestamp units and a session-identifier interpretation are not established for
+these ECG fields. Multiple outputs from the same acquisition pass can share
+sequence and timestamp. Do not globally deduplicate raw/filtered or live/historical
+records solely by that pair.
+
+## Packed status: bytes 21–33
+
+This is a **13-byte packed region**, not a 17-byte structure of unpacked booleans.
+Preserve unknown codes and raw bytes alongside any derived presentation.
+
+The described quality handler produces numeric codes 0–3. Reset clears presence
+and quality; later transitions can set presence with quality 1, then quality 2 or
+3. Another transition clears both. These are partial state-machine outcomes, not
+an exhaustive enum or a bad/good/excellent scale. Keep presence separate from
+clinical signal quality and preserve unknown codes.
+
+In the described quality-input path, consecutive nonzero per-input flags increment
+an unsigned counter capped at 65535; zero resets that run counter. Once the count
+exceeds 100, it submits a transition input that, from the quality handler's state
+0, sets presence and quality to 1. These counts are processed input entries, not
+milliseconds. Later checks in the same iteration can submit further inputs, so
+this does not guarantee the next transmitted status or readiness. The handler's
+internal state 0 is distinct from the wire classifier fields below; nonzero flags
+are not an established electrical-contact or clinical-quality classification.
+
+| Offset | Width | Meaning and limitation |
+|---:|---:|---|
+| 21 | 1 | Quality code; thresholds and vocabulary unresolved |
+| 22 | 1 | State-transition/presence bits described below |
+| 23 | 1 | Classifier result code; no established diagnostic interpretation |
+| 24 | 1 | Classifier state code |
+| 25 | 1 | Progress value; percentage-like, complete range/termination contract unresolved |
+| 26 | 1 | Four independent booleans packed into bits 0–3; individual names unresolved |
+| 27 | 1 | HR-related classifier value; average/current distinction unresolved |
+| 28 | 1 | Additional HR-related value for R17; **zero placeholder for R16** |
+| 29 | 2 | Additional HRV-related value; units unresolved |
+| 31 | 1 | Zero placeholder in this version; not a measured stress value |
+| 32 | 2 | Declared waveform sample count |
+
+Byte 22 encodes the following state relationship:
+
+- Bit 1 is set when the current classifier state equals 1.
+- Bit 0 is additionally set when that state is entered from a previous state other
+ than 1.
+- Bit 2 is set when state 2 follows state 1.
+- Bit 3 carries the presence indication.
+
+These are state codes, not established clinical states. Presence and quality
+metadata can be transmitted; do not describe lead information as universally
+absent from ECG records. Presence does not establish electrode acceptance,
+clinical signal quality or a diagnosis. Contact thresholds, debounce behavior and
+wall-clock settling time remain unknown. Counted-zero settling can be extended by subsequent inputs; no fixed wait establishes readiness.
+
+## R17 filtered waveform
+
+The frame length is exactly **240 bytes**:
+
+| Region | Encoding |
+|---|---|
+| 0–33 | Envelope, shared header and status |
+| 34–233 | **100 two-byte sample slots** |
+| 234–235 | Two zero alignment bytes; never a waveform sample |
+| 236–239 | CRC32 over bytes `[8,236)` |
+
+The declared count at 32 is separate from the fixed storage capacity. Accept only
+a count within the 100-slot capacity for ordinary decoding; reject or quarantine
+larger counts as anomalies. Do not silently truncate them. Read only the declared
+number of samples, even though every frame reserves all 100 slots. Zero-valued
+samples can be meaningful; nonzero detection is not a substitute for the count.
+
+these slots contain **signed i16 little-endian**
+values. Physical scale remains unresolved; signed numerical representation does
+not supply a voltage calibration. A gating condition can deliberately insert
+zero-valued samples into the output queue. Counted zeros therefore remain entries
+and must not be discarded as padding or used alone to conclude that generation
+stopped. Do not assume that the final conversion guarantees a saturating amplitude
+clamp.
+
+Do not read `[34,236)` as 101 samples: its last word is alignment, not data.
+Sample capacity and notification cadence do not establish the sample rate.
+
+### Output ratio and conditional count bound
+
+The standard startup configuration emits one filtered queue value per five
+processed input samples. This is a count ratio, not an independently established
+sample rate. With at most 500 accepted raw inputs per update, normal grouping
+state and an empty output queue before that update, at most 100 filtered entries
+are produced. This bound assumes no concurrent or intervening configuration change.
+
+The retrieval queue nevertheless has 250 slots and no final 100-entry clamp.
+Backlog, alternate configuration and other scheduling states are not covered by
+that conditional bound. Continue rejecting or quarantining a declared R17 count
+above 100. The normal-path explanation does not enlarge the frame's capacity or
+justify silently truncating an anomalous count.
+
+## R16 raw waveform and lead diagnostics
+
+The frame length is exactly **1,584 bytes**:
+
+| Offset / range | Encoding |
+|---|---|
+| 32–33 | u16 declared waveform count; capacity 500 |
+| 34–1533 | **500 fixed slots of three bytes each** |
+| 1534 | Lead-off diagnostic count, one byte |
+| 1535–1556 | 11 fixed I-channel halfwords |
+| 1557–1578 | 11 fixed Q-channel halfwords |
+| 1579 | Zero alignment byte |
+| 1580–1583 | CRC32 over bytes `[8,1580)` |
+
+The status map matches R17 except byte 28 remains zero. Fixed regions do not move
+when either count is smaller. In particular, a variable formula based on
+`header + count × width` will locate the lead arrays incorrectly. Treat waveform
+counts above 500 or diagnostic counts above the available 11 slots as anomalies;
+never read outside those capacities. Exact diagnostic count/timing invariants are
+not established.
+
+For the three bytes `b0, b1, b2` of each waveform slot, the independently expressed
+wire decoding rule is:
+
+```text
+raw18 = ((b0 & 0x03) << 16) | (b1 << 8) | b2
+flag6 = (b0 >> 6) & 1
+flag7 = (b0 >> 7) & 1
+```
+
+This is an 18-bit payload plus flags, not little-endian i16 and not a signed 24-bit
+sample. Retain bits 2–5 as uninterpreted reserved bits. the waveform uses **signed two's-complement 18-bit coding**: after reconstructing
+`raw18`, values below 131072 remain unchanged; values at or above 131072 subtract
+262144. The numerical range is **−131072 through 131071**. Keep flag bits 6/7
+separate; they are not sign bits. This establishes coding, not volts per count.
+
+Flag 6 is supplied per sample. Flag 7 comes from a slower contact/lead-state stream,
+so its timing must not be treated as an independently sampled 500-entry contact
+channel. When the slower array's count is ten, the grouping divisor is derived
+from raw sample count divided by ten. For the specific **500 raw / 10 slower**
+case, the boundary sample uses the earlier flag before the index advances:
+
+| Slower entry | Raw sample indices using its flag |
+|---:|---|
+| 0 | 0–50 |
+| 1 | 51–100 |
+| 2–8 | 101–150 through 401–450, respectively |
+| 9 | 451–499 |
+
+The group sizes are 51, eight groups of 50, and 49. Do not substitute ten equal
+50-sample groups. This relates transmitted indices, not independently established
+electrical measurement times. Other partial-buffer invariants remain unresolved.
+A zero slower count omits flag-7 insertion; nonzero counts other than ten are not
+established as ordinary supported input to this grouping rule.
+
+The I/Q halfwords have a signed diagnostic interpretation but no established
+physical units. Preserve raw words as well as an optional signed view. Do not use
+their sign or magnitude as a clinical threshold.
+
+## Decoder and session requirements
+
+An implementation integrating this contract needs:
+
+1. Version-aware wrist encoding and explicit support for all four live/save gates.
+2. Packet type **and** layout selection, exact frame size, checksum checks and count
+ bounds before sample extraction.
+3. Separate R16/R17 status parsers, fixed capacities and padding handling; no
+ fallback to the generic 17-byte Labrador header for these revisions.
+4. Raw values retained for unknown enum codes, flag semantics, waveform units and
+ timestamp fields. No medical label inferred from a classifier field name.
+5. Distinct session outcomes for command failure, acknowledged initialization,
+ waveform reception and stop/cleanup. Status changes alone are not samples.
+
+NOOP's earlier right/left zero/one mapping, 101-halfword filtered slice, and generic
+count-based raw payload interpretation are incompatible with these versioned
+contracts. This document specifies the required behavior; it does not assert that
+all application paths already implement it.
+
+Still unresolved are physical voltage scale, raw/filtered rates, an unconditional
+valid runtime filtered-count bound, complete hardware prerequisites, contact acceptance,
+classifier-code semantics and clinical validity. Neither 500 raw slots nor 100
+filtered slots establishes a rate, and no fixed session duration is established.
+
+## Constructed parser checks
+
+Run `python3 docs/protocol-examples/validate_examples.py` from the repository root.
+The [standalone example](protocol-examples/validate_examples.py) checks R17 count
+bounds and padding exclusion, signed i16/i18 edge cases, raw18/flag separation and
+the specified 500/10 contact-index boundaries using constructed values. Its buffers
+deliberately have no valid transport framing or checksums. These checks exercise
+the documented arithmetic; they do not independently validate device behavior,
+NOOP integration, the universal filtered-count bound or physical calibration.
+
+
+## Startup, settling and interpretation
+
+Select the intended wrist and check the command response before starting ECG
+generation. A successful wrist response confirms acceptance of the selection;
+it does not establish immediate hardware application or persistence across reboot.
+A wrist change during an active session should not be presented as immediately
+applied without separate confirmation.
+
+Control live output, historical saving and generation independently. A start
+acknowledgement confirms that the request was accepted for processing. Confirm
+actual generation using validated revision-specific ECG records and their declared
+sample counts. Command refusal, no incoming records, valid zero-valued samples and
+status changes are distinct observations.
+
+Valid counted samples can be zero during initial settling or later signal
+rejection. The standard startup gate begins at 179 processed-input iterations. With the
+normal initial selection phase and no extension, the first 36 selected outputs
+are counted zeros and the next selected output is at input 181. These conditional
+counts are not milliseconds or a guaranteed startup waveform. Settling can be
+extended by subsequent input conditions. A zero per-input flag proposes 930
+remaining processed-input iterations; an input at or below the configured signed
+lower bound, or at or above the upper bound, proposes 680; an absolute step at or
+above its configured threshold proposes 280. A candidate
+replaces the remaining count only when larger. Other holdoffs control which checks
+run, and these checks precede the block output-loop countdown. These are processing
+counts, not milliseconds or electrode/clinical thresholds. Do not infer
+readiness from a fixed delay, or classify a session solely from a flat waveform.
+Preserve numeric quality, presence, state and classifier fields; their clinical
+meaning is not established here. Preserve unknown values for later interpretation.
+
+Use sample-index axes and uncalibrated amplitude unless rate and scale have been
+independently established for the applicable configuration. Fixed record capacity
+and the number of notifications do not establish absolute sampling rate. Retain
+out-of-capacity counts as anomalies. During cleanup, stop generation and disable
+the live/save gates enabled for the session.
+
+A calibration also requires the effective clock, gain and reference configuration
+and the complete conversion from input codes through processing to output values.
+Register definitions alone would not establish that chain. Buffer capacity is not
+a sample frequency, and numerical scaling constants alone do not identify volts.
+
+## Front-end application and calibration boundary
+
+ECG initialization attempts front-end register writes and compares readback bytes.
+That comparison does not check every underlying transport return status. Software
+initialization success or a cached configuration value therefore does not prove
+that all requested hardware settings took effect. A frame-divider change writes
+two ordered bytes and can fail partway through.
+
+The numerical divider, FIFO count and one-in-five filtered selection do not
+establish absolute sample frequency or voltage scale. FIFO items can have
+different tags; an item count is not necessarily an ECG-only sample count. Use
+sample index and native amplitude unless a matching calibration is available.
diff --git a/docs/PROTOCOL_IMPLEMENTATION.md b/docs/PROTOCOL_IMPLEMENTATION.md
new file mode 100644
index 0000000000..504ff482f6
--- /dev/null
+++ b/docs/PROTOCOL_IMPLEMENTATION.md
@@ -0,0 +1,562 @@
+# NOOP implementation and historical observations
+
+This page records client behavior and earlier observations; it does not override the topic references. Read the [scope and compatibility](PROTOCOL.md#scope-and-compatibility) before applying this page.
+
+## Reading this page
+
+These notes preserve application choices and previous observations. For wire
+contracts use the [topic index](PROTOCOL.md#reading-guide); the legacy tables here
+must not override it.
+
+- [Client command inventory](#6-commandnumber-sending--the-safe-subset)
+- [Probes and their limits](#whoop-40-reboot-probe-235)
+- [Offload state machine](#73-session-state-machine)
+- [Decoded output](#8-decoded-output-parsedframe)
+- [SpO₂ observation and import boundaries](#10-spo₂-on-50--mg--what-the-wire-does-and-does-not-carry)
+- [Implementation file map](#11-file-map)
+
+
+## Diagnostic-only WHOOP service families
+
+The official app also models additional WHOOP service families with the same `0001` service plus
+`0002`/`0003`/`0004`/`0005`/`0007` characteristic pattern. NOOP lists these as protocol metadata and
+logs them when advertised, but does not connect, discover characteristics, or send commands for them
+until the correct framing is mapped and hardware-tested.
+
+| Family label in NOOP | Service UUID | Current status |
+|----------------------|--------------|----------------|
+| `puffin1150` | `11500001-6215-11ee-8c99-0242ac120002` | detected but unsupported |
+| `monument` | `8a580001-2fe8-4796-9267-b87a2b0c8234` | detected but unsupported; likely Castle/Rev2 framing |
+| `symphony` | `59830001-5955-419b-bb8d-c8262926af23` | detected but unsupported; likely Castle/Rev2 framing |
+
+
+
+## Family-aware entry points
+
+```swift
+public func verifyFrame(_ frame: [UInt8], family: DeviceFamily) -> FrameCheck
+public func parseFrame(_ frame: [UInt8], family: DeviceFamily) -> ParsedFrame
+```
+
+`whoop4` behaves exactly like the no-family overloads (back-compat). The "puffin" types
+`38 PUFFIN_COMMAND_RESPONSE` and `56 PUFFIN_METADATA` are aliased onto `COMMAND_RESPONSE` /
+`METADATA` by `canonicalTypeName(_:schema:)` so they never decode as "unknown".
+
+
+
+## Reassembly
+
+BLE notifications arrive as MTU-sized fragments. For the **WHOOP 4 envelope**,
+`Reassembler` (`Framing.swift`) accumulates bytes, finds the `0xAA` SOF, reads the `u16` LE
+length at `buf[1..3]`, and emits a complete frame once `buf.count ≥ length + 4`.
+WHOOP 5/MG format 1 instead reads length at offset 2 and uses complete size `length + 8`;
+select the family/format before applying either rule. See [framing](PROTOCOL_TRANSPORT.md#format-1-framing). Leading garbage before an SOF is discarded; a buffer with
+no SOF is dropped. The app feeds the data/cmd/event notify characteristics through one
+`Reassembler` in `peripheral(_:didUpdateValueFor:error:)`.
+
+```swift
+// usage in BLEManager
+for frame in reassembler.feed(bytes) {
+ router.handle(frame: frame) // UI/state
+ // … live ingest or backfill routing …
+}
+```
+
+`frameFromPayload(_:type:seq:cmd:)` reconstructs a complete frame from a bare payload (used when
+a capture stored only the data portion): it rebuilds the envelope with a correct zlib CRC32 and
+a placeholder `0x00` CRC8 byte.
+
+---
+
+
+
+## PacketType (offset `[4]`, or `[8]` on 5.0)
+
+This is NOOP’s schema vocabulary, not a guarantee that every named packet is produced by either generation. Current WHOOP 5/MG layouts are in [sensor records](PROTOCOL_SENSORS.md).
+
+Source: `enums.PacketType` in `whoop_protocol.json`; resolved by `Schema.typeName(_:)`.
+
+| Value | Name | Notes |
+|------:|------|-------|
+| 35 | `COMMAND` | outbound command (app → strap) |
+| 36 | `COMMAND_RESPONSE` | reply to a command |
+| 37 | `PUFFIN_COMMAND` | WHOOP 5.0 command |
+| 38 | `PUFFIN_COMMAND_RESPONSE` | WHOOP 5.0; aliased → `COMMAND_RESPONSE` |
+| 40 | `REALTIME_DATA` | live HR / R-R |
+| 43 | `REALTIME_RAW_DATA` | live raw sensor data; the reference baseline also carries ECG R16/R17 ([ECG](PROTOCOL_ECG.md)); older ~1.9 KB IMU/optical examples are not a universal layout |
+| 47 | `HISTORICAL_DATA` | offloaded biometric records |
+| 48 | `EVENT` | strap event (event table below) |
+| 49 | `METADATA` | offload control metadata (history topic) |
+| 50 | `CONSOLE_LOGS` | firmware log text |
+| 51 | `REALTIME_IMU_DATA_STREAM` | |
+| 52 | `HISTORICAL_IMU_DATA_STREAM` | |
+| 53 | `RELATIVE_PUFFIN_EVENTS` | WHOOP 5.0 |
+| 54 | `PUFFIN_EVENTS_FROM_STRAP` | WHOOP 5.0 |
+| 55 | `RELATIVE_BATTERY_PACK_CONSOLE_LOGS` | |
+| 56 | `PUFFIN_METADATA` | WHOOP 5.0; aliased → `METADATA` |
+
+`isOffloadFrame(_:)` (in `BLEManager`) treats **47/48/49/50** as offload traffic; the live
+`REALTIME_DATA`(40)/`REALTIME_RAW_DATA`(43) flood is excluded so it cannot keep the backfill
+idle-watchdog alive.
+
+The parser also exposes irregular fields through per-type **post-hooks**
+(`registerPostHooks()` in `PostHooks.swift`): `realtime_data`, `event`, `command_response`,
+`raw_data`, `historical_data`, `metadata`, `console_logs`. The static field layout per packet
+comes from the schema's `packets` table. The legacy WHOOP 4 raw-data model is keyed by
+payload length (`"1917"` = IMU, `"1921"` = optical), and its historical data by version
+byte (`seq`). WHOOP 5/MG needs its versioned record layout; those two length keys do not
+identify every live sensor or ECG packet. See [sensor records](PROTOCOL_SENSORS.md).
+
+---
+
+
+
+## EventNumber (`EVENT`, type 48)
+
+WHOOP 4 `EVENT` frames carry an `EventNumber` at `[6]` and a `u32` `event_timestamp` at `[8]`.
+For WHOOP 5/MG format 1 the corresponding offsets are `[10]` and `[12]`; other event
+types need their own layouts. See [sensor/event records](PROTOCOL_SENSORS.md). A
+strap-pushed event is WHOOP's "strap-as-clock" signal: NOOP treats any event as "I may have new
+data" and kicks a rate-limited sync (`FrameRouter.onSyncTrigger` → `requestSync(.strap)`).
+Selected, frequently-used values (full table in `whoop_protocol.json`):
+
+| Value | Name | | Value | Name |
+|------:|------|-|------:|------|
+| 3 | `BATTERY_LEVEL` | | 42 | `ACCELEROMETER_SATURATION_DETECTED` |
+| 7 | `CHARGING_ON` | | 46 | `RAW_DATA_COLLECTION_ON` |
+| 8 | `CHARGING_OFF` | | 47 | `RAW_DATA_COLLECTION_OFF` |
+| 9 | `WRIST_ON` | | 56 | `STRAP_DRIVEN_ALARM_SET` |
+| 10 | `WRIST_OFF` | | 57 | `STRAP_DRIVEN_ALARM_EXECUTED` |
+| 13 | `RTC_LOST` | | 58 | `APP_DRIVEN_ALARM_EXECUTED` |
+| 14 | `DOUBLE_TAP` | | 59 | `STRAP_DRIVEN_ALARM_DISABLED` |
+| 17 | `TEMPERATURE_LEVEL` | | 60 | `HAPTICS_FIRED` |
+| 23 | `BLE_BONDED` | | 63 | `EXTENDED_BATTERY_INFORMATION` |
+| 32 | `CAPTOUCH_AUTOTHRESHOLD_ACTION` | | 96 | `HIGH_FREQ_SYNC_PROMPT` |
+| 33 | `BLE_REALTIME_HR_ON` | | 97 | `HIGH_FREQ_SYNC_ENABLED` |
+| 34 | `BLE_REALTIME_HR_OFF` | | 98 | `HIGH_FREQ_SYNC_DISABLED` |
+| 40 | `CH1_SATURATION_DETECTED` | | 100 | `HAPTICS_TERMINATED` |
+| 41 | `CH2_SATURATION_DETECTED` | | | |
+
+`FrameRouter` maps several physical events to UI callbacks: `BLE_BONDED` confirms bonding,
+`DOUBLE_TAP` fires `onDoubleTap`, `WRIST_ON`/`WRIST_OFF` toggle `worn` and fire `onWristChange`.
+The legacy WHOOP 4 `BATTERY_LEVEL` event decoder uses this layout (see the `event` post-hook):
+`soc% = u16@17 / 10`, `mV = u16@21`, `charging = u8@26 & 1`.
+
+---
+
+
+
+## CommandNumber (sending) — client subset
+
+**Historical NOOP sender inventory.** The table below records client payload conventions, primarily WHOOP 4. It is not the WHOOP 5/MG command contract or a recommendation to send every listed operation. Use the [command reference](PROTOCOL_COMMANDS.md) for current meanings and the [alarm reference](PROTOCOL_ALARMS.md) for revisioned alarms.
+
+NOOP exposes a curated, **safe** command set in `WhoopCommand` (`Strand/BLE/Commands.swift`).
+The raw value is the on-wire command byte at `[6]` (inside a type-35 `COMMAND` frame). Commands
+are built by `WhoopCommand.frame(seq:payload:)` and written to `…0002`.
+
+```swift
+public func frame(seq: UInt8, payload: [UInt8] = [0x00]) -> [UInt8] {
+ let inner: [UInt8] = [35 /* COMMAND */, seq, rawValue] + payload
+ let length = UInt16(inner.count + 4)
+ let lenBytes: [UInt8] = [UInt8(length & 0xFF), UInt8(length >> 8)]
+ return [0xAA] + lenBytes + [crc8(lenBytes)] + inner + crc32(inner) /* LE */
+}
+```
+
+| Code | Command | Typical payload | Purpose |
+|-----:|---------|-----------------|---------|
+| 1 | `LINK_VALID` | — | link keep-alive |
+| 3 | `TOGGLE_REALTIME_HR` | `[0x01]`/`[0x00]` | start/stop live HR stream (type-40) |
+| 7 | `REPORT_VERSION_INFO` | — | firmware versions (decoded by `command_response` hook) |
+| 10 | `SET_CLOCK` | `[secs u32 LE][subsecs u32 LE]` | set strap RTC (UTC) |
+| 11 | `GET_CLOCK` | *empty* | read RTC → `ClockRef` correlation |
+| 22 | `SEND_HISTORICAL_DATA` | `[0x00]` | begin offload of the type-47 store |
+| 23 | `HISTORICAL_DATA_RESULT` | `[0x01] + end_data(8)` | ack a `HISTORY_END` chunk / advance trim |
+| 26 | `GET_BATTERY_LEVEL` | `[0x00]` | battery percent; also the **bond** write |
+| 34 | `GET_DATA_RANGE` | `[0x00]` | strap's stored oldest/newest record range; #689 also logs a diagnostic ring-buffer page backlog — see below |
+| 35 | `GET_HELLO_HARVARD` | `[0x00]` | identity/version hello; the response carries the 4.0 strap serial — see below |
+| 39 / 40 | `SET_LED_DRIVE` / `GET_LED_DRIVE` | — | optical LED drive (research) |
+| 41 / 42 | `SET_TIA_GAIN` / `GET_TIA_GAIN` | — | optical front-end gain (research) |
+| 43 / 44 | `SET_BIAS_OFFSET` / `GET_BIAS_OFFSET` | — | optical bias (research) |
+| 63 | `SEND_R10_R11_REALTIME` | `[0x00]` off / `[0x01]` on | the **real** type-43 raw-stream switch |
+| 66 | `SET_ALARM_TIME` | `[0x01]+epoch u32 LE+[0,0]` | arm firmware alarm |
+| 67 | `GET_ALARM_TIME` | `[0x01]` | read armed alarm |
+| 68 | `RUN_ALARM` | `[0x01]` | app-driven alarm now |
+| 69 | `DISABLE_ALARM` | `[0x01]` | disarm firmware alarm |
+| 76 | `GET_ADVERTISING_NAME_HARVARD` | `[0x00]` | advertised name |
+| 79 | `RUN_HAPTICS_PATTERN` | `[patternId, loops, 0,0,0]` | buzz a preset haptic pattern |
+| 80 | `GET_ALL_HAPTICS_PATTERN` | — | enumerate preset patterns |
+| 81 / 82 | `START_RAW_DATA` / `STOP_RAW_DATA` | `[0x01]` | raw-data collection toggle |
+| 84 | `GET_BODY_LOCATION_AND_STATUS` | — | wrist/body-location status (read-only diagnostic probe, #690 — below) |
+| 96 / 97 | `ENTER_HIGH_FREQ_SYNC` / `EXIT_HIGH_FREQ_SYNC` | `[0x00]` | high-freq offload mode |
+| 98 | `GET_EXTENDED_BATTERY_INFO` | — | extended battery (mV etc.) |
+| 100 | `CALIBRATE_CAPSENSE` | — | recalibrate cap-touch |
+| 105 / 106 | `TOGGLE_IMU_MODE_HISTORICAL` / `TOGGLE_IMU_MODE` | `[0x01]` | IMU stream mode |
+| 107 | `ENABLE_OPTICAL_DATA` | — | optical (PPG) data |
+| 117 | `START_FF_KEY_EXCHANGE` | `[0x01]` | how many feature flags the firmware knows (read-only enumeration probe, #761 — below) |
+| 118 | `SEND_NEXT_FF` | `[0x01]` | next feature-flag NAME (cursor, not index; read-only, #761 — below) |
+| 122 | `STOP_HAPTICS` | `[0x00]` | stop an in-progress haptic |
+| 123 | `SELECT_WRIST` | — | set strap wrist |
+
+**5/MG raw-IMU sequence (hardware-verified):** command 106 accepting a write does not mean that the
+producer started. A bounded capture first sends `START_RAW_DATA` (81) `[0x01]`, then command 106 with
+the two-byte selector `[0x01, 0x01]`. Stop uses `STOP_RAW_DATA` (82) `[0x01]`, then command 106
+`[0x01, 0x00]`. The one-byte payload in the table remains the WHOOP 4 form. See
+[5/MG raw data capture](RAW_DATA_CAPTURE.md) for storage, history repair, and export semantics.
+
+**Payload builders** in `WhoopCommand`:
+
+- `setAlarmPayload(epochSec:)` → `[0x01] + epoch u32 LE + [0x00, 0x00]` (7 bytes).
+- `BLEManager.setClockPayload(now:)` → `[secs u32 LE][0,0,0,0]` (8 bytes; subseconds in
+ 1/32768 s, zero is fine).
+
+> **Note on `ENTER_HIGH_FREQ_SYNC` (96):** current builds do **not** enter high-freq sync; they
+> send `EXIT_HIGH_FREQ_SYNC` (97) defensively on connect to release a strap a previous app may
+> have parked there. Plain `SEND_HISTORICAL_DATA` returns the type-47 store without it.
+
+## Additional 5-class command numbers
+
+Command bytes present on a 5-class (MAVERICK) strap beyond the safe subset above. NOOP does not
+send these; they are recorded for completeness.
+
+| Code | Command | Purpose |
+|-----:|---------|---------|
+| 48 (0x30) | `SEND_EVENT_PACKETS` | event-delivery toggle; past/live selection unresolved |
+| 61 (0x3D) | `SET_AFE_PARAMETERS` | set optical AFE parameters |
+| 62 (0x3E) | `GET_AFE_PARAMETERS` | read optical AFE parameters |
+
+On MAVERICK the clock commands also answer in the high opcode space — `SET_CLOCK` at 146 (0x92)
+and `GET_CLOCK` at 147 (0x93), alongside `GET_HELLO` at 145 (0x91) — distinct from the 4.0
+numbers (10 / 11) above.
+
+The ECG family is resolved as wrist selection (123), processing start/stop
+(124), raw saving (125), raw live delivery (126), filtered saving (127), and filtered live
+delivery (139). Noncontiguous IDs are not evidence of a mistaken mapping. Requests and
+packet contracts are in [ECG](PROTOCOL_ECG.md); all remaining IDs are covered by the
+[complete command reference](PROTOCOL_COMMANDS.md).
+
+The turn-on ORDER and the 124 argument are attested on one device. On a WHOOP MG (`WS50_r00`, the earlier ECG observation), 139 gates the **stream**: with it off nothing arrives, so the working sequence is
+**`139 = 1` then `124 = 2`**, after which type-43 carries a ~100 Hz single-channel i16 waveform,
+present only while both clasp electrodes are held. 139 does not appear to gate the front end itself —
+with 139 closed, `124 = 2` still made the strap's own `CONSOLE_LOGS` report `MAX86176: Set ECG ON`
+while no packets arrived (eight sends, eight console lines, correlated on the strap's own uptime;
+#891). Both directions are reversible (`124 = 1` or `139 = 0` stop the stream, both `SUCCESS`);
+disconnecting also clears it. One device, one firmware — see the ⚠️ on `ControlSignal`.
+
+What is confirmed on the other device: on a real WHOOP 5 MG (`WS50_r03`), 124, 125 and 139 are all
+**accepted** — each answers `COMMAND_RESPONSE` with result `SUCCESS(1)` — and no ECG-shaped data
+followed in a 30-second window. Those runs used `124 = 1` as their start verb, which under the mapping
+above stops generation. That is a null result from a run that did not use the demonstrated start argument; it does not
+establish a feature gate or contradict the later version-bound mapping. See
+#891. The three reply frames are pinned as decode fixtures in `Whoop5CommandResponseTests` /
+`CommandCatalogueTest`.
+
+NOOP sends these only from the gated, hand-run MG ECG probe described in
+[ECG controls](PROTOCOL.md#91-ecg-labrador-on-the-mg) — never automatically, never on a plain 5.0 or a 4.0, and only
+behind the Experimental opt-in plus a positively-identified MG. Existing probe implementation and
+older observations must be distinguished from the expanded contract.
+
+live IMU control is 106 and BLE UART control is 103; they are distinct
+operations. See [collection controls](PROTOCOL_CONFIGURATION.md#collection-storage-and-live-transport).
+
+The configuration probing notes below describe earlier client behavior and unanswered
+runs. They do not override the reference baseline [named configuration contract](PROTOCOL_CONFIGURATION.md):
+read commands are defined, SET consumes a 65-byte body, lookup eligibility is versioned,
+and tri-state polarity is per key. Historical enumeration and timeout reports are not
+current absence-of-support claims.
+
+## Destructive commands — *do not send*
+
+These exist on the wire but are **deliberately excluded** from `WhoopCommand`. They can wipe
+data, brick, or power-cycle the strap. NOOP must never send them.
+
+| Code | Command | Hazard |
+|-----:|---------|--------|
+| 25 | `FORCE_TRIM` | invasive history cursor/reclamation operation; unoffloaded data may become unavailable |
+| 32 | `POWER_CYCLE_STRAP` | power-cycles (gated probe exception — see below) |
+| 36 | `START_FIRMWARE_LOAD` | firmware write |
+| 37 | `LOAD_FIRMWARE_DATA` | firmware write |
+| 38 | `PROCESS_FIRMWARE_IMAGE` | firmware write |
+| 45 | `ENTER_BLE_DFU` | enters DFU bootloader |
+| 99 | `RESET_FUEL_GAUGE` | resets battery fuel gauge |
+| 142 | `START_FIRMWARE_LOAD_NEW` | firmware write |
+| 143 | `LOAD_FIRMWARE_DATA_NEW` | firmware write |
+| 144 | `PROCESS_FIRMWARE_IMAGE_NEW` | firmware write |
+
+The 142–144 family is the high-opcode-space counterpart of 36/37/38, in the same style as the clock
+family answering at 145–147 on MAVERICK. It is named by the schema and absent from the sender enum on
+both platforms; it was missing from this table, so nothing recorded that it must stay that way. (83
+`VERIFY_FIRMWARE_IMAGE` is part of the same flow but is not itself a write, and is likewise unsent.)
+
+**Two guarded restart paths in NOOP.** These client paths are not proof of retained state or completed restart for every device. Neither is ever sent automatically or on any connect/offload path.
+
+- **`REBOOT_STRAP` (29)** — the normal Restart. NOOP already triggers a reboot today via
+ `SET_ADVERTISING_NAME_HARVARD` (rename applies on reboot). In `WhoopCommand` as `rebootStrap`, sent only
+ from the user-initiated, confirmation-gated "Restart strap" action (`BLEManager.rebootStrap()` /
+ `WhoopBleClient.rebootStrap()`) (#166).
+- **`POWER_CYCLE_STRAP` (32)** — a harder restart, in the enum as `powerCycleStrap` **only** as a candidate
+ for the WHOOP 4.0 reboot probe (below). Sent only from `rebootProbe(.powerCycle32Empty)`, itself gated
+ behind Test Centre → Connection + a confirmation, and 4.0-only. Never on a default install.
+
+Everything else in this table stays out of the enum entirely.
+
+## WHOOP 4.0 reboot probe (#235)
+
+ A real 4.0 silently ignores the production `REBOOT_STRAP` frame (see
+below) and the correct 4.0 reboot frame is unknown. The probe (Test Centre → Connection, 4.0 only) sends
+one candidate at a time — `REBOOT_STRAP(29)` empty, `POWER_CYCLE_STRAP(32)` empty, or
+`REBOOT_STRAP(29)` with `[0x01]` — reusing the reboot watchdog so the strap log shows which one drops the
+link (worked) vs is ignored. The definitive fix is still an HCI capture of the official app rebooting a
+4.0 (the way the alarm frame was pinned, #535). Driven by `BLEManager.rebootProbe(_:)` /
+`WhoopBleClient.rebootProbe(...)`; candidates enumerated in `RebootProbeVariant`.
+
+## Body-location probe (#690)
+
+This paragraph records the older client decoder; the [current response body](PROTOCOL_COMMANDS.md#ordinary-service-commands) is documented separately.
+
+ A read-only, user-triggered diagnostic (Test Centre → Connection, both
+families) that sends `GET_BODY_LOCATION_AND_STATUS` (84 / `0x54`) and dumps the strap's full raw
+COMMAND_RESPONSE to the strap log + a copyable dialog. The 4-byte inner-payload record is
+`revision · location · confidence · status`; `location` maps `0 UNKNOWN, 1 WRIST, 2 BICEP, 3 CALF,
+4 SIDE_TORSO, 5 GLUTE, 7 ANKLE, 128 NOT_CONCLUSIVE, 160 UNKNOWN_GARMENT` (any other value — including the
+gap at 6 — is kept raw; `confidence`/`status` stay raw until captures establish their semantics). Decoded
+only on WHOOP 4.0, where the inner payload starts at the command byte + 1; on 5/MG the puffin envelope's
+result code sits where `location` would land, so the raw grid is shown and the record is left undecoded
+until a real 5/MG capture maps the offset. **Never** feeds wear detection, sleep gating, or scoring.
+Driven by `BLEManager.probeBodyLocationAndStatus()` / `WhoopBleClient.probeBodyLocationAndStatus()`;
+formatted by the pure `BodyLocationProbe` twin (Swift↔Kotlin byte-parity locked by a golden test). The
+layout + enum facts are reverse-engineered from the WHOOP app and reimplemented in NOOP's own code
+(facts, not copied expression — see [`ATTRIBUTION.md`](../ATTRIBUTION.md)).
+
+## Feature-flag enumeration probe (#761, read-only)
+
+The probe’s older count model differs from the current u8 field; use the [named configuration interface](PROTOCOL_CONFIGURATION.md#named-configuration-interface).
+
+ NOOP has always been able to WRITE a feature flag
+(`SET_FF_VALUE` / 120, the R22 unlock in `Whoop5Config`) but never to ASK a strap which flags it knows.
+The `CommandNumber` table names a full symmetric read side that was never implemented — 117
+`START_FF_KEY_EXCHANGE` / 118 `SEND_NEXT_FF` for feature flags, 115 / 116 for device config — and this
+probe uses the enumerate pair only: **names, no values, nothing written.** `GET_FF_VALUE` (128) is
+deliberately not sent: the only hands-on report of it (`johnmiddleton12/wearable`, run on the author's
+own WHOOP 4.0 on the earlier WHOOP 4 baseline) states its reply's value field is contaminated by a stale shared buffer,
+so an on/off read is unreliable; the same session ran the 117→118 loop and got a complete key dump.
+
+Requests and reply fields are specified in the [named configuration interface](PROTOCOL_CONFIGURATION.md#named-configuration-interface).
+The older probe’s count decoder is not the current byte contract.
+
+**The two terminator conditions are not interchangeable, and are separated deliberately.** The walk stops
+on `index = 0xFF` — the one end marker a strap has served here unambiguously. `validKey = 0` on its own
+does NOT stop it: that could equally mark an EMPTY or RETIRED SLOT with the list continuing past it, and
+the old probe interpretation was not established as a complete WHOOP 5/MG decoder. Neither reading is
+established, because on the walks this project has, the two have never been separated on the wire: the
+117/118 walk on a WS50_r03 served sixteen replies that were all `validKey = 1` with no `0xFF` at all,
+and its 115/116 walk ended on a single reply carrying `index = 255` **and** `validKey = 0` together. So a
+`validKey = 0` entry is recorded, stepped over, and the next record verb is sent again — what comes back
+separates the two readings, and the report states which it observed. Past that the bounds are all
+CLIENT-side and each names itself in the report's `Stop code:` line: 8 consecutive `validKey = 0` replies,
+a repeated index during such a run (a parked cursor — evidence for the terminator reading), the announced
+count plus 4, or a hard cap of 128 replies. Each next-record request is only sent after the previous reply
+lands. Both CRCs are verified before any field is read; a failed CRC, a non-COMMAND_RESPONSE type, or a
+short record ends the walk with a named reason instead of a decode, and the RAW record bytes of every
+reply are logged beside the fields decoded from them. Driven by `BLEManager.probeFeatureFlags()` /
+`WhoopBleClient.probeFeatureFlags()` (user-triggered, Test Centre → Connection, both families) and
+allowlisted for 5/MG framing **only while a probe is in flight**; parsed + rendered by the pure
+`FeatureFlagProbe` / `FeatureFlagProbeReport` twins (Swift↔Kotlin byte-parity, unit-tested on synthetic
+frames). Result goes to a copyable dialog + the strap log; no storage. The field order and opcode numbers
+are facts read off a decompiled official client's response types and corroborated by that 4.0 dump,
+reimplemented in NOOP's own code — facts, not copied expression (see [`ATTRIBUTION.md`](../ATTRIBUTION.md)).
+**Historical probe scope:** the published comparison dump is a 4.0's R19-era list. The
+the reference baseline enumeration commands and eligible-key inventory are now described in
+[configuration](PROTOCOL_CONFIGURATION.md); this does not validate every older reply layout.
+
+## Device-config read probe (#103, read-only)
+
+The NOOP probe queries named values using commands 121 and 128, with a 64-round-trip
+client cap. It is user-triggered and reports to a dialog and strap log without
+persisting values. The allowlist restricts this path to reads; it does not send
+119 or 120. Swift/Kotlin implementations have constructed-frame checks.
+
+The current [request and complete response contract](PROTOCOL_CONFIGURATION.md#configuration-reads--commands-121-and-128)
+is documented independently of the probe’s partial decoder. Earlier probe runs
+did not establish successful hardware readback. Neither key names nor a missing
+measurement establish an entitlement or subscription gate.
+
+## GET_DATA_RANGE ring backlog (#689, diagnostic only)
+
+ Beyond the oldest/newest timestamps NOOP already
+scans from a `GET_DATA_RANGE` reply, the app computes a ring-buffer page backlog from three u32s in the
+command-response inner payload (whose byte 0 is a subtype): write page `W = V(2)`, acknowledged/trim boundary `D = V(3)`,
+ring capacity `T = V(5)`, where `V(i)` is the u32 at inner offset `i·4 + 1` (frame offsets `cmdOff + 10/14/22`
+here). In the current WHOOP 5/MG [range layout](PROTOCOL_TRANSPORT.md#data-range--command-34),
+the read-page cursor is `V(1)`; `V(3)` measures the acknowledged boundary instead.
+Backlog with wraparound: `W < D ? W + (T − D) : W − D`. `DataRange.pagesBehind` (Swift + Kotlin twins,
+byte-parity, unit-tested for normal / wraparound / too-short / implausible) logs `Strap backlog pages behind:
+N` when it decodes plausibly — read u32 LE, guarded on frame length + a capacity sanity ceiling. **Never**
+gates sync or backfill: the layout is RE'd from the WHOOP app (facts, reimplemented in NOOP's own code, see
+[`ATTRIBUTION.md`](../ATTRIBUTION.md)) but **not yet confirmed against real 4.0 / 5-MG captures**, so it stays
+a log-only diagnostic until a fixture pins the offsets + endianness.
+
+**Payload forms** (decoded from the official app's command builders — recorded so the wire format is
+*known*: for the destructive commands, known-and-avoidable; for the one guarded exception,
+`REBOOT_STRAP`, known-and-used by `rebootStrap()`). The opcodes are shared across WHOOP 4 (harvard)
+and WHOOP 5/MG (puffin): the app's unified command enum (`EnumC58479e`) uses the same `25`/`29`/`32`
+on both transports — unlike haptics, which has a maverick-specific `0x13`.
+
+- `FORCE_TRIM` (25) — body is **two little-endian int32 range args**. One app-built
+ form sets both to `-16843010` (`0xFEFEFEFE`, builder `rh0.C45484g`:
+ `new C45484g(-16843010, -16843010)`). It is **not** an empty/`[0x00]` payload.
+ In the WHOOP 5/MG profile, this pair enters the same history-storage event path
+ as the chunk acknowledgement: it selects a special mode and the current write
+ boundary. This is an invasive cursor/reclamation operation; it does not establish
+ physical erasure of the entire flash history or guarantee that every stored
+ record becomes unavailable. See [special history acknowledgement tokens](PROTOCOL_TRANSPORT.md#history-sequencing-and-storage-ownership).
+- `REBOOT_STRAP` (29) — **empty body** (builder `rh0.C45476d0` passes a null payload). The strap drops
+ the BLE link and re-advertises after boot; stored data is kept. Non-destructive, but interrupts any
+ in-flight offload. **WHOOP 5.0 (puffin): hardware-confirmed** — the empty-body frame reboots a 5.0
+ (the earlier reboot observation, #227). **WHOOP 4.0 (harvard): NOT confirmed** — a real 4.0 silently ignores this
+ empty-body frame (#235: no reboot, no disconnect, no COMMAND_RESPONSE), so the correct 4.0 form (a
+ payload byte? a different opcode?) still needs an HCI capture of the official app rebooting a 4.0.
+
+---
+
+
+
+## Session state machine
+
+```
+SEND_HISTORICAL_DATA([0x00], .withResponse)
+ │
+ ▼
+HISTORY_START ─▶ open chunk, accumulate type-47 records
+ │
+ ├─ HISTORICAL_DATA … HISTORICAL_DATA … (records buffered)
+ │
+ ├─ HISTORY_END(unix, trim) ──▶ finishChunk:
+ │ 1. decode chunk (extractHistoricalStreams, using ClockRef)
+ │ 2. await store.insert(decoded) ── decoded durable
+ │ 3. [if raw enabled] await enqueueRawBatch ── raw durable
+ │ 4. await setCursor("strap_trim", trim) ── cursor durable
+ │ 5. ackTrim → HISTORICAL_DATA_RESULT([0x01]+end_data, .withResponse)
+ │ (chunk cleared; chunkOpen stays TRUE — high-freq sends repeated ENDs)
+ │
+ └─ HISTORY_COMPLETE ─▶ isBackfilling = false, close session
+```
+
+High-frequency offload sends **one** `HISTORY_START` then **repeated** `HISTORY_END`s (a chunk
+close roughly every ~50 records), so `Backfiller.begin()` starts with `chunkOpen = true`, and
+`finishChunk(...)` snapshots-and-clears the accumulated frames but leaves the chunk open so the
+following records form the next chunk. An `END` with no accumulated records is **still acked**
+(that is how the offload progresses).
+
+
+
+## Safe-trim invariant
+
+NOOP sends the normal chunk acknowledgement only after local durability. This is a client persistence invariant; it does not prove all device read/erase behavior or exactly-once delivery. From
+`Backfiller.finishChunk(...)`:
+
+```
+decode → await insert(decoded) → [await enqueueRawBatch] → await setCursor("strap_trim") → ackTrim
+```
+
+Any thrown error in that sequence short-circuits before the client sends the ack. The
+ack itself is the link-layer half: `HISTORICAL_DATA_RESULT(23)` with payload `[0x01] + end_data`
+written `.withResponse`. A BLE write confirmation is not itself proof of physical erasure or power-loss durability. The
+`strap_trim` cursor is persisted, so the client retains progress for another attempt; exact device replay after disconnect is not guaranteed. This local progress does not depend on a network.
+
+
+
+## Watchdog & liveness
+
+- **Idle watchdog** (`backfillIdleTimeoutSeconds = 60`): re-armed on every genuine offload frame
+ (47/48/49/50) and only those; if the strap goes silent the session exits and resumes next time
+ via the durable cursor. The live type-43 flood is dropped during offload so it cannot starve
+ chunk acks.
+- **Stuck detector** (`StuckStrapDetector`): after an offload, if the strap reports records newer
+ than NOOP's frontier (from `GET_DATA_RANGE`, parsed by `dataRangeNewestUnix(from:)`) **and**
+ that frontier has been frozen for the detector window, it flags `strapNeedsReboot` and attempts
+ a defensive recovery (`EXIT_HIGH_FREQ_SYNC` + `SET_CLOCK`). Off-wrist / caught-up (strap not
+ ahead) is **not** treated as stuck.
+
+---
+
+
+
+## Decoded output (`ParsedFrame`)
+
+`parseFrame(_:)` returns a `ParsedFrame` with the validated envelope, a typed field list
+(`[DecodedField]`), and a flat `parsed: [String: ParsedValue]` dictionary that downstream code
+reads. Key entries by packet type:
+
+| Packet | `parsed` keys (examples) |
+|--------|--------------------------|
+| `REALTIME_DATA` (40) | `heart_rate`, `rr_intervals` |
+| `REALTIME_RAW_DATA` (43) | `heart_rate`, `rr_intervals`, IMU axis means, `ppg_mean` |
+| `EVENT` (48) | `event`, `battery_pct`, `battery_mV`, `battery_charging` |
+| `COMMAND_RESPONSE` (36) | `battery_pct`, `clock`, `fw_harvard`, `fw_boylston`, `history_oldest`, `history_newest` |
+| `HISTORICAL_DATA` (47) | `hist_version`, schema-versioned biometric fields, `rr_intervals` |
+| `METADATA` (49) | `meta_type`, `unix`, `subsec`, `trim_cursor` |
+| `CONSOLE_LOGS` (50) | `log` (capped at 2048 chars) |
+
+`HISTORICAL_DATA` (type-47) layout is selected by the version byte (`seq`) via
+`Schema.resolveVersion(_:_:)`, which follows a `ref` chain (e.g. V12 → V24) so newer versions
+inherit a base layout and override only what changed. The streamed decode that feeds SQLite is in
+`Streams.swift` / `HistoricalStreams.swift` (`extractStreams`, `extractHistoricalStreams`).
+
+---
+
+
+
+## SpO₂ on 5.0 / MG — what the wire does and does not carry
+
+No dedicated SpO₂ read operation is identified in the current command reference.
+R18 byte 82 has no established physiological meaning. NOOP imports
+`blood_oxygen_pct` as a per-cycle value; that importer does not establish the
+vendor's aggregation or calibration algorithm. See the
+[raw-record interpretation limits](PROTOCOL_SENSORS.md).
+
+
+
+## File map
+
+| Path | Responsibility |
+|------|----------------|
+| `Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift` | SOF/length/CRC8/CRC16/CRC32, `verifyFrame`, `Reassembler`, `frameFromPayload` |
+| `Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift` | `parseFrame` (4.0 + 5.0), `ParsedFrame`, field builder |
+| `Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceFamily.swift` | UUID strings, header-CRC kind, `CLIENT_HELLO`, puffin aliasing |
+| `Packages/WhoopProtocol/Sources/WhoopProtocol/Schema.swift` | JSON schema model + `loadSchema()` |
+| `Packages/WhoopProtocol/Sources/WhoopProtocol/PostHooks.swift` | per-type irregular-field decoders |
+| `Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift` | `classifyHistoricalMeta` (START/END/COMPLETE) |
+| `Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json` | canonical enums + packet layouts |
+| `Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift` | MG ECG ("Labrador") packet decode + command construction |
+| `Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5EcgProbe.swift` | ECG turn-on report + the run-scoped result-code verdicts |
+| `Strand/BLE/BLEManager.swift` | CoreBluetooth transport, bond, connect lifecycle, backfill orchestration |
+| `Strand/BLE/Commands.swift` | safe `WhoopCommand` set + outbound frame builder |
+| `Strand/BLE/FrameRouter.swift` | decode → `LiveState` (UI) |
+| `Strand/BLE/StandardHeartRate.swift` | `0x2A37` HR/R-R parser |
+| `Strand/Collect/Backfiller.swift` | historical-offload state machine + safe-trim invariant |
+
+---
+
+*Reverse-engineering credit: `johnmiddleton12/my-whoop` (WHOOP 4.0) and `b-nnett/goose`
+(WHOOP 5.0). This is an independent interoperability project for the user's own device and data;
+it is not affiliated with WHOOP and is not a medical device.*
+
+## Earlier command-response observations
+
+**The first body byte is per-command, and is not a status flag.** Use the [complete battery response](PROTOCOL_TRANSPORT.md#battery-level--command-26); the older first-byte observation below does not establish a one-byte current body. `GET_BATTERY_LEVEL` puts the charge
+percentage there — `47` in the hardware-confirmed fixture — so the slot carries real data. On other
+commands it has only ever been observed as `1`:
+
+| capture | command | result | first body byte |
+|---|---|---|---:|
+| real 5/MG | `GET_BATTERY_LEVEL` | SUCCESS | **47** (= 47%) |
+| real 5/MG | `GET_DATA_RANGE` | SUCCESS | 1 |
+| real MG | `SELECT_WRIST`, accepted | SUCCESS | 1 |
+| real MG | `SELECT_WRIST`, refused | FAILURE | 1 |
+| real MG | `TOGGLE_LABRADOR_*` | SUCCESS | 1 |
+
+For the revision-1 wrist and ECG controls, the first response-body byte is a
+literal revision marker, not wrist or enable-state readback. The historical accepted/refused
+captures above remain observations of their respective runs; identical body bytes do not
+prove that a requested state was applied. See [ECG](PROTOCOL_ECG.md).
diff --git a/docs/PROTOCOL_SENSORS.md b/docs/PROTOCOL_SENSORS.md
new file mode 100644
index 0000000000..a5a87615aa
--- /dev/null
+++ b/docs/PROTOCOL_SENSORS.md
@@ -0,0 +1,518 @@
+# WHOOP 5/MG sensor records
+
+Applicability: [central scope and compatibility](PROTOCOL.md#scope-and-compatibility).
+
+This companion to [the main protocol reference](PROTOCOL.md) specifies measurement
+records and their validity boundaries. It complements the historical
+[WHOOP 5 data experiment](WHOOP5_DEEP_DATA.md) and
+[optical collection guide](WHOOP5_OPTICAL_EXPERIMENT.md).
+For ECG R16/R17, use [the ECG contract](PROTOCOL_ECG.md).
+
+Frame shapes, count capacities and processing qualifications follow the central
+scope. Earlier NOOP physical scales and timing conventions remain explicitly
+qualified; they do not establish a new hardware calibration or every device’s
+sensor configuration.
+
+## Packet types, record layouts and integrity
+
+All offsets are absolute in the **reassembled WHOOP 5/MG format-1 frame**.
+Integers and IEEE-754 floats are little-endian unless stated otherwise. Verify
+framing, declared length, header CRC16 over `[0,6)`, and body CRC32 over
+`[8,frame_length-4)` before interpreting fields. Offsets are not notification-local;
+a record can span BLE fragments.
+
+| Packet type at byte 8 | Meaning and routing boundary |
+|---:|---|
+| 40 | Live heart rate and R-R intervals; separate map below |
+| 43 | Live sensor data; R16 and R17 ECG use a layout selector at byte 9. Type alone does not identify a waveform. |
+| 47 | Historical data; byte 9 selects R16, R17, R18, R20, R21, R26 or another record layout. Preserve unknown layouts. |
+| 48 | Event; WHOOP 5 event number at byte 10 and u32 event timestamp at byte 12. Variable payloads require event-specific handling. |
+| 51 | Dedicated realtime IMU stream; [partial client layout](#dedicated-imu-stream-types-51-and-52), current strap producer unconfirmed. |
+| 52 | Dedicated historical IMU stream; [partial client layout](#dedicated-imu-stream-types-51-and-52). Do **not** assume type 47/R21. |
+
+Packet number, record layout, command number and event number are separate
+namespaces. WHOOP 4 type-43 shapes must not be imported by shifting offsets alone.
+The 1,917-byte WHOOP 4 IMU and 1,921-byte optical variants do not define WHOOP 5
+records. Current START/END/COMPLETE messages use metadata type 49. Preserve type 56 compatibility where older supported-device evidence requires it; no universal release boundary is established.
+
+For R18/R20/R21/R26, byte 9 is the layout, u32 at 11 is a record index and u32 at
+15 is a Unix-seconds timestamp. The index is not a timestamp: do not fill time gaps
+by counting records or assume rollover/reset behavior. Unmapped fields must remain
+opaque, not silently converted to zero measurements.
+
+## Packet 40: live HR and R-R
+
+| Offset | Width / type | Meaning |
+|---:|---|---|
+| 10 | 4 / u32 | Unix-seconds timestamp |
+| 14 | 2 / u16 | Additional time field; scale and epoch coupling unresolved |
+| 16 | 1 / u8 | Heart rate, bpm |
+| 17 | 1 / u8 | Declared interval count |
+| 18 + 2i | 2 / u16 | R-R ticks, 1/1024 second |
+
+Read at most the declared number of complete words before the CRC trailer. Omit
+zero intervals and never consume an incomplete word. For positive ticks, integer
+milliseconds are `(ticks * 1000 + 512) // 1024`. Use a wide enough intermediate
+for multiplication. No packet-local quality flag or independent absolute timestamp
+per interval is established. Do not reuse the unknown additional time field's
+scale from an unrelated clock command.
+
+## R18: biometric summary
+
+The established summary shape is **124 bytes**, with CRC at 120. The following
+measurement conventions include earlier NOOP decoding; they are not all physical
+sensor guarantees for every firmware version.
+
+| Offset | Width / encoding | Field, scale and validity |
+|---:|---|---|
+| 9 | 1 / u8 | Layout 18 |
+| 11 | 4 / u32 | Record index |
+| 15 | 4 / u32 | Unix seconds; reject implausible dates according to the application's time-range policy |
+| 22 | 1 / u8 | Heart rate, bpm; retain quality context from byte 36 |
+| 23 | 1 / u8 | R-R count; NOOP reads at most four complete positive words |
+| 24 + 2i | 2 / u16 | Up to four R-R words; 1/1024 s, same rounded-ms conversion as packet 40 |
+| 33 | 1 / u8 | Cardiac-adjacent flags, meanings unresolved |
+| 36 | 1 / u8 | HR/R-R quality flags; bit 7 is the earlier NOOP validity interpretation, not independently established here for the reference baseline |
+| 37 | 1 / u8 | Alternate HR in bpm; earlier NOOP convention uses byte-36 bit 7 as its acceptance gate; validity for the reference baseline remains unresolved |
+| 38 | 2 / u16 | R-R-adjacent packed word; unit/meaning unresolved |
+| 41 | 4 / f32 | Dynamic, gravity-removed acceleration; g in NOOP's convention, accept finite values in [0,8] |
+| 45, 49, 53 | 4 each / f32 | Gravity x/y/z, g; no established per-axis sentinel |
+| 57 | 2 / u16 | Selected cumulative step/motion counter; selection detailed below |
+| 59 | 2 / u16 | Cadence-like raw value, supplied from u8; high byte is zero |
+| 61 | 2 / u16 | Hardware counter when software override is active, otherwise zero |
+| 63 | 1 / u8 | Activity: 0 still, 1 walk, 2 run; other input classes become FF and must not be surfaced as those three classes |
+| 64 | 1 / u8 | 10 hex when software counter override is active, otherwise zero |
+| 69, 71 | 2 each / i16 | Auxiliary thermal channels, raw/10 °C in NOOP; accept 0–60 °C |
+| 73 | 2 / u16 | Skin-temperature convention, raw/100 °C; accept 5–45 °C |
+| 75, 77, 79 | 2 each / u16 | Raw status words; no sleep-stage meaning established |
+| 81 | 1 / bitfield | Four two-bit groups; detailed below |
+| 82 | 1 / u8 | Sleep-adjacent raw byte; 80/A0 hex are candidate sentinels, not established physiological labels |
+| 106, 107 | 1 each / u8 | Optical baseline-like raw values; per-byte optical identity provisional |
+| 108, 109 | 1 each / u8 | Optical amplitude-like values; simultaneous 128 is NOOP's signal-quality sentinel interpretation |
+| 113 | 4 / f32 | Unknown finite float; zero may mean unset, no established quantity |
+| 120 | 4 / u32 | CRC32 over `[8,120)` |
+
+The temperature encodings quantize the corresponding inputs with
+factors ten and one hundred before integer conversion. This does not establish
+which physical sensors supply the auxiliary channels. Keep application plausibility
+ranges distinct from manufacturer-defined validity flags.
+
+Byte-36 bits 4 and 5 include [source-selection and hold-state contributions](#r18-quality-adjacent-source-selection-bits). Do not infer alternate HR validity from
+its numerical agreement with the main HR. The optical byte pairs are each encoded
+from a byte-reversed halfword; that structure does not independently identify two
+wavelengths. The baseline pair has an off-wrist zero interpretation, while the
+amplitude pair's simultaneous 128 is a quality interpretation, not an SpO₂ value.
+
+The larger tail remains raw. Bytes 83–103, 105, 110–112 and 117–119 have a
+zero-filled convention in the available decoder coverage, and byte 104 a marker;
+these are not universal sentinels. Do not reject a future record solely because a
+previously constant tail changes.
+
+### Step source, cadence and activity
+
+the normal value at 57 is the hardware pedometer count. When software
+counter override is enabled, 57 carries the software count, 61 retains the hardware
+count and 64 becomes `0x10`. With no override, bytes 61–62 and 64 are zero. This
+allows a client to retain both sources and avoid joining a change of source into a
+false step delta.
+
+The hardware tuple contains count, cadence-like byte and activity class. A
+cadence value is **not a documented steps-per-minute conversion**; even its
+monotonic relationship to speed is not guaranteed. Preserve it raw. Neither
+counter is established as equal to the official app's aggregated step count.
+Rollover, reset and day boundaries remain unresolved; a timestamped counter must
+not be advertised as a midnight-reset daily total. Byte 63 also has older
+quality-oriented naming, so retain the raw byte with the selected activity label.
+
+### Motion/rest state and override
+
+Byte 81 packs four independent two-bit values:
+
+| Bits | Interpretation |
+|---|---|
+| 0–1 | On-wrist/validity-related value; raw vocabulary incomplete |
+| 2–3 | Wake-quality-related value; raw vocabulary incomplete |
+| 4–5 | Band motion/rest state |
+| 6–7 | Additional state value; raw vocabulary unresolved |
+
+For the ordinary baseline motion/rest path, `(byte81 >> 4) & 3` has the labels
+**0 WAKE, 1 STILL, 2 SLEEP, 3 UP**. An override can retain a selected state instead
+of updating it from the motion/rest classifier. The override's operating modes
+are unresolved and the packet does not provide an established way to identify
+all of them. Preserve the raw state, and present labels as band state rather than
+an unconditional physiological measurement.
+
+Nonzero state labels do not have complete runtime validation for this version.
+SLEEP is not a mapping to light, deep or REM sleep, and is unrelated to processor
+power-saving sleep. Byte 75 is not a deep-sleep indicator. The record supplies
+neither a validated hypnogram nor an established production SpO₂ value.
+
+## R18 quality-adjacent source-selection bits
+
+The packed byte at frame 36 includes source-selection contributions. In the
+traced producer, bit 4 is added when an alternate-source selection branch is
+active, and bit 5 is added both there and under a subsequent hold condition.
+The same byte also receives the low four bits of a separate source through an
+OR operation, and another source can add bit 6. These contributions can coexist
+with bits 4 and 5. They do not establish a single quality enum, a complete
+validity mask or physiological labels.
+
+The numeric selector has the following bounded transitions. Here `x` and `y` are
+internal numeric scores, `-128` is missing input, and counts are calls, not seconds
+or calibrated quality measures.
+
+| State | Selected transition rule |
+|---|---|
+| 0 | Wait ten count increments, then enter 1 for `x` in `[-127,12]`. |
+| 1 | Return to 0 for `x > 20`; otherwise missing `x` or `y` retains 1, and `y > x + 7` enters 2. |
+| 2 | Wait ten increments, then retain 2 only for `x` in `[-127,20]`, `y != -128`, and `y > x + 7`. |
+
+State 2 contributes the override only with the additional enable predicates;
+these transitions alone are not a client readiness test.
+
+The hold counter is set to 10 by that branch. Once the branch stops, it is
+reduced before testing; bit 5 can therefore remain without bit 4 for nine further
+qualifying updates. Continued selector state 1 or 2 is required; state 0 stops this
+contribution immediately. Update counts are not a wall-clock duration, and this
+is not an unconditional ten-record grace period.
+
+Preserve the raw byte. These contributions do not establish clinical quality,
+bit 7 validity, or a sufficient rule for accepting or discarding a reading.
+
+The numeric inputs behind the source-selection contributions in bits 4/5 have
+separate histories. After a history reset, the first four enabled updates supply
+a missing-input value. Updates 5–59 use a quantized cumulative mean of a
+transformed internal score; update 60 starts an approximately 2% new / 98% retained
+state smoother. These are update counts, not seconds or a sample-rate guarantee.
+A configuration-change path resets both histories. Combined with the selector
+and hold counter, this prevents treating bits 4/5 as an instantaneous quality rank.
+The score's physiological meaning remains unspecified.
+
+A separate contribution to the flags word is an internal cached numeric value
+shifted by 8 and ORed with other contributions. For finite inputs it is zero when
+nonpositive; positive inputs are clamped to 30–210 and rounded to the nearest
+integer, with positive half values rounded upward. Its units and other writers
+to the resulting byte remain unresolved. Do not interpret the numeric range as
+proof of heart-rate or quality semantics.
+
+## R20: optical blocks
+
+R20 is exactly **2,140 bytes**: a 26-byte header, five 422-byte blocks, then CRC32
+at 2136 covering `[8,2136)`. It is not a checksum over only the blocks. Layout is
+20 at byte 9; byte 10 is a marker, often `0x81`, not an established validity flag.
+Record index/time use the common offsets 11/15.
+
+Block bases `B` are **26, 448, 870, 1292, 1714**. Each block has a shared 21-byte
+configuration header, two 200-byte sample regions and one reserved byte.
+
+| Relative offset | Width / encoding | Meaning |
+|---:|---|---|
+| B+0 | 1 / u8 | Valid sample count **per slot**, 0–50 |
+| B+1 | 1 / u8 | Source/emitter selector A; enum unresolved |
+| B+2 | 2 / u16 | Drive/configuration A; physical unit unresolved |
+| B+4 | 1 / u8 | Source/emitter selector B; enum unresolved |
+| B+5 | 2 / u16 | Drive/configuration B; physical unit unresolved |
+| B+7 | 1 / u8 | Detector routing A; enum unresolved |
+| B+8 | 4 / u32 | Range A; physical unit unresolved |
+| B+12 | 2 / i16 | Offset A; physical unit unresolved |
+| B+14 | 1 / u8 | Detector routing B; enum unresolved |
+| B+15 | 4 / u32 | Range B; physical unit unresolved |
+| B+19 | 2 / i16 | Offset B; physical unit unresolved |
+| B+21+4i | 4 / i32 | Slot A sample i, raw optical count |
+| B+221+4i | 4 / i32 | Slot B sample i, raw optical count |
+| B+421 | 1 / u8 | Reserved; zero in established historical convention |
+
+All **50 positions in each of ten columns** occupy the frame even if the count
+is smaller. Decode only `0 <= i < count`; count zero means no populated readings,
+not fifty measured zeros. Reject counts above 50. The signed containers cover an
+optical domain convention of −524288 through 524287, including the positive rail;
+negative values must not become large unsigned measurements. The encoding alone
+does not provide a calibrated optical unit.
+
+For a constructed count-only example, block counts `[12,0,0,12,12]` represent
+`3 × 2 × 12 = 72` populated values, despite 500 available positions. This example
+contains no measured samples.
+
+### Configuration and conditional routing
+
+The fourth block (zero-based block 3) has an alternate source pair. When the
+primary source has no samples, a fallback source supplies that block, including
+its count, and marker bit 0 is set. Do not interpret the fourth block as a permanently
+fixed optical channel or its marker as a quality verdict.
+
+On the known configuration-to-metadata producer path, drive/configuration values
+are quantized as
+`(((input + 5) mod 2^32) // 10) mod 2^16`: the addition wraps as u32 before
+unsigned division, then the result is stored as u16. This rounding/truncation does not establish milliamps or another
+upstream physical unit. Historical conventions include ranges 16/32 and offsets
+in multiples of 800. In the first-block configuration join, accepted offset
+settings 0/8000/16000/24000 produce signed metadata values 0/800/1600/2400.
+This join does not establish every block's complete routing or a physical unit.
+A zero-drive fourth block has served as a dark control; that
+pattern is not a guarantee under every routing configuration. The two slots share
+one header and remain **A/B**, not red/infrared/green. Detector geometry, wavelength,
+source enums and calibrated drive/range/offset units remain unresolved.
+
+## R21: six-axis IMU
+
+R21 has fixed length **1,244 bytes**, with CRC32 at 1240 covering `[8,1240)`.
+Layout is 21 at 9 and the marker at 10 commonly `0x80`; neither marker alone proves
+measurement quality. Sequence and Unix-seconds base time are at 11 and 15.
+
+| Offset | Width / encoding | Meaning |
+|---:|---|---|
+| 21, 22 | u8 each | Configuration values 4 and 100 |
+| 24 | u16 | Accelerometer valid count, capacity 100 |
+| 26, 27 | u8 each | Configuration byte 3, followed by an uninterpreted configuration byte |
+| 28+2i | i16 | Acceleration x |
+| 228+2i | i16 | Acceleration y |
+| 428+2i | i16 | Acceleration z |
+| 628 | u8 | Configuration value 100 |
+| 630 | u16 | Gyroscope valid count, capacity 100 |
+| 632, 633 | u8 each | Configuration byte 5, followed by an uninterpreted configuration byte |
+| 640+2i | i16 | Gyroscope x |
+| 840+2i | i16 | Gyroscope y |
+| 1040+2i | i16 | Gyroscope z |
+
+The six columns each reserve 200 bytes. Counts are independent u8 values widened
+to u16, so their high bytes are zero in this version. All column capacity remains
+present when counts are smaller. Bounds-check each count against 100 and consume
+only that group's valid values. Do not require accelerometer and gyroscope counts
+to be equal merely because earlier NOOP decoding accepted only 100/100 buffers.
+Keep that existing strict decoder gate distinguishable from the broader record
+capacity contract.
+
+NOOP’s earlier IMU convention scales acceleration as `raw / 4096` g,
+gyroscope as `raw * 2000 / 32768` degrees/s, and places sample i at
+`base_time + i/100` for a 100 Hz, one-second buffer. The fixed layout does not
+independently establish those physical settings or a timing rule for
+partly populated buffers. Configuration bytes above do not yet form a proven
+range enum. Preserve raw values/counts when active scaling or timing is unknown.
+
+No per-sample quality bit, axis-to-strap/body geometry, counter rollover, timestamp
+jitter rule or active range configuration is established here. Structurally valid
+six-axis data is not proof of a body orientation.
+
+## Inertial record timestamps
+
+layout 21 is carried by live packet 43 and historical
+packet 47. Its little-endian timestamp has Unix seconds at frame offset 15 and a
+u16 fraction at offset 19. Combine them as `seconds + fraction / 32768`.
+
+The fraction represents a clock reading quantized to hundredths of a second.
+Its finer binary representation does not imply finer acquisition precision.
+For valid clock readings, fractional words range from 0 to 32440. This statement
+covers layout 21; it does not establish timing jitter, clock validity or the
+fractional scale of other layouts.
+
+No matching strap producer is established for packets 51/52. The [partial client
+layout](#dedicated-imu-stream-types-51-and-52) below provides separate count/span
+bounds; do not decode these packets as layout 21.
+
+Command 106 changes requested live motion state, which is staged and applied later. Successful driver application enables the packet 43/layout 21 publisher. Failure or rapid opposite requests can leave active and requested state different; see [collection coordination](PROTOCOL_CONFIGURATION.md#collection-and-live-stream-coordination). No packet 51/52 producer is established by this route.
+
+## R22 inner version
+
+the R22 inner version is byte 21 of the complete frame, followed
+by subversion at byte 22. The frame remains 188 bytes across the selected version
+paths. These bytes are distinct from layout 22 at byte 9 and outer format tag 3
+at byte 6. [Version preferences](PROTOCOL_CONFIGURATION.md#r22-version-preferences)
+can fall back or use queued data. Preserve unknown versions; neither a preference
+name nor the fixed wrapper size supplies the full body schema. No packet 51/52
+layout follows from this R22 selection path.
+
+For the R18 quality byte at frame offset 36, the reference baseline contract combines
+multiple bit contributions rather than a single established classifier enum.
+Preserve the raw byte. This does not establish clinical meanings or confirm the
+earlier bit-7 validity interpretation for this version.
+
+## R22 version 9 queued channels and sample encoding
+
+the v9 body contains a channel identifier at complete-frame byte 141.
+The current-output path uses identifier 0 and stages bodies for identifiers 1..5.
+These identifiers distinguish numeric input channels; their physical mapping
+and units remain unresolved.
+
+The current-versus-replay decision tests the first 32-bit input words of numeric
+channels 1 and 2. Both zero selects replay; otherwise current output is built.
+These are value tests, not valid-count or complete-sensor-availability tests.
+A replayed body does not prove that all current sensor inputs were absent.
+
+When this predicate switches to replay, one saved body is emitted
+per preparation call in channel order **1,2,4,5,3**, draining each channel before
+the next. A saved body is not necessarily a fresh reading. Empty queues produce
+**version 4**, even when version 9 has priority in the configuration. Always decode
+the actual inner version at byte 21.
+
+The described preparation/replay path finally compares version and subversion
+together as a little-endian 16-bit number. Values above 9 become version 1,
+subversion 0: 9/1 is normalized, whereas 0/0 is unchanged by this check. This is a
+producer rule, not a client whitelist or a rule for every R22 builder.
+
+Each of the five traced queues has 60 slots in its normal count range. At
+capacity, new writes replace the last slot and retain the first 59; this is not
+a rotating window of the most recent 60 records. Replay order is not a promise
+of chronological order across channels. Adding current data does not itself
+rewind replay cursors. No maximum replay age or guaranteed number of delivered
+records is established.
+
+For emitted version 9/subversion 0, offsets below are from the complete 188-byte
+frame; the body starts at byte 21:
+
+| Offset | Size | Meaning |
+|---|---|---|
+| 21 | 1 | Inner version 9 |
+| 22 | 1 | Subversion 0 |
+| 23 | 4 | Raw packed metadata; complete meaning unresolved |
+| 27 | 2 | Raw packed metadata; complete meaning unresolved |
+| 29 | 2 | Raw numeric field; meaning unresolved |
+| 31 | 4 | Initial sample bit pattern |
+| 35 | 98 | 49 little-endian signed 16-bit adjacent-sample differences |
+| 133 | 4 | Raw metadata; meaning unresolved |
+| 137 | 2 | Packed channel-selected metadata; [subfields](#r22-version-9-metadata-refinement) below |
+| 139 | 1 | Predicate contribution on the selected producer path; meaning/freshness unresolved |
+| 140 | 1 | Zero in this version's builder |
+| 141 | 1 | Numeric channel identifier 0..5 |
+| 142 | 42 | Opaque tail; do not assume zero |
+
+Adjacent subtraction first wraps to 32 bits and is interpreted as signed, then
+clipped to `[-32768,32767]`. For example, difference bits `0xffffffff` mean -1 at
+this stage, not a large positive jump. Cumulative signed deltas reconstruct
+50 sample bit patterns modulo 2^32 only when no difference was clipped; clipping
+is lossy. Preserve the initial 32-bit pattern. Neither the encoding nor its
+channel identifier establishes calibrated units, physical signedness or sample
+frequency. The 42-byte tail is outside the sample encoding.
+
+Keep body bytes 121–162 opaque. The output uses retained storage, and other body
+versions write within this region. A version 9 write does not itself clear those
+bytes. Nearby working-buffer and replay-pool clearing does not establish that
+this output tail is zero. Ignore the tail when decoding version 9; do not use its
+contents as a freshness marker or as extra sample values.
+
+The R18 validity convention and v8 remain unresolved. The v9 addition does not
+define packets 51/52; their [partial client contract](#dedicated-imu-stream-types-51-and-52) is separate.
+
+## R22 version 9 metadata refinement
+
+The little-endian word at complete-frame bytes 137..138 (body 116..117) is
+**packed metadata**, not a scalar gain, amplitude or quality score. Preserve its
+raw value alongside any extracted subfields.
+
+For the current producer, bits 0..1 identify metadata group 0, 1 or 2. Channel IDs
+0/1/2 use group 0; IDs 3/4 use group 1; ID 5 uses group 2. Bit 2 is an additional state
+contribution for group 2; bit 3 is zero in this producer. The channel ID remains
+at frame 141 (body 120), and these group numbers do not establish physical
+wavelengths or electrode assignments.
+
+When the metadata source is available, bits 4..5 and 6..7 contain two separate
+2-bit values and bits 8..11 contain a 4-bit value. Their individual meanings
+remain unspecified. The upper nibble combines shifted source bytes; do not
+assign four independent boolean meanings until those source values are defined.
+When the source is unavailable, the producer emits `group_tag | 0x0c00`.
+**That pattern is not a unique availability indicator:** the available path can
+produce the same word. Do not reject a record solely because its metadata equals
+that pattern.
+
+Frame 139 (body 118) has a producer that extracts one flag bit and stores it as 0
+or 1; the meaning of that predicate remains unspecified. This does not guarantee
+freshness on every record: the preparation path can be skipped, and queued v9
+records carry their saved metadata. The 42-byte tail at frame 142..183 remains
+opaque, with no universal zero guarantee.
+
+## R26: compact optical window
+
+R26 is exactly **88 bytes**, CRC at 84 covering `[8,84)`. It carries an optical
+base value plus **24 adjacent deltas**, not 24 independent absolute readings.
+NOOP models the 25-sample window as one second; physical wavelength and calibrated
+sample units remain unresolved.
+
+| Offset | Width / encoding | Meaning |
+|---:|---|---|
+| 9 | u8 | Layout 26 |
+| 11 | u32 | Retained record index |
+| 15 | u32 | Retained Unix-seconds timestamp |
+| 19 | u16 | Additional timestamp word; tick scale unresolved |
+| 21 | u16 | Burst counter; wraps modulo 65536 |
+| 23 | u32 | Absolute first optical code, retained raw |
+| 27+2i | i16, i=0…23 | Adjacent delta for sample i+1 |
+| 75 | f32 | Summary motion value; physical conversion not established here |
+| 79 | u16 | Summary status, raw |
+| 81 | u8 | On-wrist-related summary value, raw |
+| 82 | u8 | Signal-acceptance result; classification vocabulary unresolved |
+| 83 | u8 | Zero padding in this version |
+| 84 | u32 | CRC32 |
+
+Decode using a sufficiently wide accumulator:
+
+```text
+sample[0] = base
+sample[i+1] = sample[i] + delta[i]
+```
+
+Each transmitted delta is clipped to **[-32768,32767]**. Therefore this reconstructs
+the transmitted approximation; it cannot recover a larger original adjacent jump.
+For a constructed numerical example, an original +50000 difference can only be
+represented as +32767, a loss of 17233 at that step. Later deltas do not inherently
+repair that lost offset because each represents another adjacent difference.
+This example is arithmetic, not a waveform capture.
+
+The burst identifier is an actual two-byte field and increments when acquisition
+enters a new burst; it is not the record index, a channel selector or a ring-slot
+index. Some NOOP paths omit zero, but zero remains possible after wrapping and is
+not established as an invalid wire value. Byte 12 belongs to the record index and
+must not be interpreted as a wavelength either.
+
+A buffered window retains its own index/time while awaiting delivery, so receive
+time or the current transport context must not replace its payload timestamp.
+Neither footer acceptance nor the on-wrist-related byte provides an established
+clinical quality vocabulary. This record does not identify simultaneous red and
+infrared channels, and carries no established R-R interval field. Do not derive
+SpO₂ from unassigned channels.
+
+## Dedicated IMU stream types 51 and 52
+
+Packet types 51 and 52 share the following partial client contract for app
+the [client baseline](PROTOCOL.md#scope-and-compatibility). A matching strap publisher and valid captured frames remain
+unconfirmed. Do not substitute the R10, R21 or R22 layout for these types.
+
+On this decoder's frame path, the complete-frame offsets 24 and 26 contain
+little-endian unsigned 16-bit accelerometer and gyroscope counts, A and G.
+The decoder computes planar array starts as follows:
+
+| Array | Complete-frame byte offset |
+| --- | --- |
+| Accelerometer X | `28` (inferred from the offset arithmetic) |
+| Accelerometer Y | `28 + 2*A` |
+| Accelerometer Z | `28 + 4*A` |
+| Gyroscope X | `28 + 6*A` |
+| Gyroscope Y | `28 + 6*A + 2*G` |
+| Gyroscope Z | `28 + 6*A + 4*G` |
+
+The calculated span ends at `28 + 6*A + 6*G`. Validate the count header and all
+calculated spans against the independently validated sample-data boundary,
+excluding checksum/trailer bytes, before allocating or reading sample arrays. These offsets are relative to the complete
+frame on the identified path, not to a stripped payload or every framing variant.
+Sample signedness, physical scale, cadence, timestamp fields and normal count
+limits remain unspecified. Preserve these packets as opaque when that frame
+contract cannot be established.
+
+## Implementation boundaries
+
+Keep versioned shape checks separate from physical interpretation. Counts determine
+valid values; fixed lengths determine buffer capacity. Do not treat padding,
+inactive optical slots, placeholder fields or zero-filled tails as measurements.
+Preserve unknown layouts and raw status alongside supported decoded streams so a
+new firmware version cannot silently inherit an incompatible decoder.
+
+These specifications do not make packet 51/52 equivalent to R21, turn ECG into
+an optical record, or make band-state SLEEP an external sleep stage. They provide
+parser and application contracts; remaining calibration, hardware and timing
+uncertainties require version-specific validation before stronger user-facing claims.
+
+## Constructed arithmetic checks
+
+Run `python3 docs/protocol-examples/validate_examples.py` from the repository root.
+The [standalone example](protocol-examples/validate_examples.py) checks clipped
+R26 reconstruction and R-R conversion with invented values, alongside ECG field
+checks. It does not validate captured records, CRC implementation, firmware
+execution, physical calibration or NOOP integration.
diff --git a/docs/PROTOCOL_TRANSPORT.md b/docs/PROTOCOL_TRANSPORT.md
new file mode 100644
index 0000000000..73e2d89de9
--- /dev/null
+++ b/docs/PROTOCOL_TRANSPORT.md
@@ -0,0 +1,367 @@
+# WHOOP transport and core behavior
+
+Applicability: [central scope and compatibility](PROTOCOL.md#scope-and-compatibility).
+
+This extends [the protocol entry page](PROTOCOL.md) with WHOOP 5/MG contracts. Earlier WHOOP 4 and 5 observations are labeled separately. A defined command does not establish identical behavior on every hardware variant or connection state. See the [complete command reference](PROTOCOL_COMMANDS.md) for individual operations.
+
+## Format 1 framing
+
+All multibyte integers below are little-endian. Offsets are from the beginning of the complete frame.
+
+| Offset | Width | Meaning |
+|---:|---:|---|
+| 0 | 1 | Start byte `aa` |
+| 1 | 1 | Format `01` |
+| 2 | 2 | Declared length: complete frame length minus 8 |
+| 4 | 2 | Header fields; requests commonly use `00 01`, responses `01 00` |
+| 6 | 2 | CRC16 over bytes 0–5 |
+| 8 | 1 | Packet type: 35 for a command |
+| 9 | 1 | Request sequence |
+| 10 | 1 | Command number |
+| 11 | variable | Request body, followed by zero padding to a four-byte body boundary |
+| end − 4 | 4 | CRC32 over the padded body beginning at offset 8 |
+
+Construct the body as packet type, sequence, command and command-specific bytes; append zero bytes until its size is divisible by four. The declared length is that padded size plus four, and the complete frame size is the padded size plus twelve. Padding is not a semantic argument: an explicit request byte `00` and a missing argument must not be treated as interchangeable merely because padding can make their frames look alike.
+
+The baseline format-1 acceptance constraints require a complete frame longer than 15 bytes, exact agreement between declared and supplied length, and `(declared length − 4)` divisible by four. Do not require one constant value for bytes 4–5. Header CRC is reflected Modbus CRC16, polynomial `0xa001`, initial value `0xffff`. NOOP format-1 bodies use the standard reflected CRC32 convention, polynomial `0xedb88320`, initial and final XOR `0xffffffff`, including padding.
+
+NOOP's existing generic verifier is more permissive about minimum length, trailing bytes and format selection. Reassemble complete frames first, distinguish outer formats, and validate the exact selected frame rather than letting a permissive checksum check imply support for another format. Parser errors for unsupported format, size, header CRC and body CRC are local failures, **not** command-result values on the wire.
+
+WHOOP 4 uses its separate envelope: `aa`, two-byte length, CRC8 over those two length bytes, then the inner body at offset 4 and its CRC32. Complete size is declared length plus four. Keep the family-specific GATT and fragment handling in [the entry page](PROTOCOL.md#2-frame-envelope).
+
+## Responses and correlation
+
+A format-1 command response has type 36 at offset 8, a generated response sequence at 9, command at 10, **originating request sequence at 11**, result at 12, and command body at 13. The request-origin byte is the correlation field; the response frame's sequence is not the request echo. Match command and origin, with connection/session context and a bounded outstanding-request policy. Sequence wrap and duplicate responses remain possible. WHOOP 4's corresponding command/origin/result/body offsets are 6/7/8/9.
+
+| Result | Meaning | Application consequence |
+|---:|---|---|
+| 0 | Failure | Preserve the refusal; do not infer its cause without command-specific information. |
+| 1 | Success | Request accepted at that command's response boundary; not proof of completed asynchronous work, sensor initialization, live packets or durable storage. |
+| 2 | Pending | Keep the request open for its later result within a bounded client timeout. |
+| 3 | Unsupported | This command is not supported in the applicable command context. |
+| Other | Unknown | Preserve the numeric value; do not coerce it to success. |
+
+Check lengths before accessing the response prefix or body; the CRC trailer and outer padding are not response fields. The first command-body byte is command-specific: for several revision-1 controls it is a revision marker, whereas command 26 begins a four-byte whole-percent value in this version. It is not a universal success flag or state echo. The 88 unsupported command IDs in the reference return result 3 with an empty semantic body command context.
+
+NOOP increments an eight-bit request sequence before sending and wraps it. Resetting it on disconnect is a client policy, not a guarantee that a late notification belongs to the new session. A command can produce multiple responses, and a retransmitted request is not automatically safe to execute twice.
+
+## Format 2 boundary
+
+A second outer format exists, but its runtime availability and session negotiation are unresolved. Normal NOOP traffic remains format 1; do not switch formats automatically. For incoming format-2 commands, inner type 7 is at offset 25, the normalized request sequence comes from byte 17, command from byte 29 and payload starts at 31. The wider outgoing fields do not establish a wider incoming command namespace.
+
+| Reply offset | Width | Meaning |
+|---:|---:|---|
+| 8 | 1 | Outer type `0x40` |
+| 9 | 1 | Revision 1 |
+| 11 | 4 | Seconds |
+| 15 | 2 | Fractional ticks |
+| 17 | 4 | Generated response sequence |
+| 25 | 1 | Inner response type 8 |
+| 26 | 1 | Zero |
+| 27 | 1 | Revision 1 |
+| 28 | 2 | Command |
+| 30 | 4 | Originating request sequence |
+| 34 | 1 | Marker 3 |
+| 35 | 4 | Result |
+| 39 | 1 | Marker 1 |
+| 40 | variable | Command response body |
+
+Unassigned gaps are zero in this reply layout. The reply length calculation narrows `(body length + 32)` to eight bits. This is not an unrestricted large-payload interface; behavior for oversized bodies remains unresolved. Format-1 offsets must never be applied to this format.
+
+## Clock and identity contracts
+
+SET_CLOCK uses revision 1, Unix seconds `u32`, and fractional ticks `u16` in units of 1/32768 second. Use a canonical fractional value 0–32767. The stored clock precision is hundredths: the fraction is converted using `floor(ticks × 100 / 32768)`. The reply is a one-byte revision-1 body with success or failure. Behavior of noncanonical fractional values is not specified here.
+
+GET_CLOCK takes revision 1 and returns seven bytes: revision, seconds `u32`, fractional ticks `u16`. Returned ticks are `floor(hundredths × 32768 / 100)`, so a set/get round trip can lose precision. An unsupported revision returns failure with revision 1 and zero time. A clock-read failure can also produce zero time with a success result; there is no independent validity flag. Applications must not treat success alone as evidence of a valid wall clock.
+
+One initialization path consumes a pending saved time. It requires a valid stored
+request, saved seconds strictly above Unix timestamp `1293840001`, and a saved time newer
+than the current valid seconds value. These eligibility comparisons use seconds,
+not the fractional field. The fractional field is supplied to the time setter
+when restoration is attempted.
+
+The pending saved time is cleared after the selected attempt, including when
+the time setter reports failure; rejected stale or invalid saved times are also
+cleared. Clearing the persistent request can itself fail. Applications should
+read the current device time after reconnecting before relying on it for
+scheduled operations. A pending saved time is not a guarantee that initialization
+restored the clock or will retry until it succeeds.
+
+The deprecated low-number clock pair has different legacy request shapes. Do not substitute its seconds-plus-four-zero-bytes body or its WHOOP 4 response offsets for the new pair.
+
+LINK_VALID returns success with a fixed 13-byte NUL-terminated acknowledgement; it is not an identity token. GET_HELLO accepts revisions 1 and 3, initially returning pending with respectively 107 or 111 body bytes, the revision echoed and other bytes zero. Invalid revision returns failure with a 107-byte body beginning with revision 1. The final layout is specified below; earlier Hello decoder offsets are not universal. Hello and battery-pack records may contain identifiers; a protocol decoder should extract only the fields needed by its feature.
+
+## History sequencing and storage ownership
+
+The existing [backfill state machine](PROTOCOL.md#7-historical-data-offload-backfill) remains the operational contract. WHOOP 5/MG clients request the range before history and wait for success or a two-second client fallback; WHOOP 4 clients can request history directly. The timeout is an application choice, not a device timing promise. The current range request first returns pending with an empty body; its final 65-byte body is specified below, with opaque cursor and timestamp roles retained. Lack of a response does not mean that history is empty.
+
+History transmission uses an explicit `00` request byte. Metadata start/end/complete values are 1/2/3. Packet types 49 and 56 both participate in known history routing; the current START/END/COMPLETE path uses type 49; other-version routing remains separately scoped. A command acknowledgement is not delivery of a chunk.
+
+For each HISTORY_END, preserve its eight-byte acknowledgement block verbatim. The first four bytes are the trim cursor in known layouts; the second four reflect write-wrap state in this version. Send the history-result body `01` followed by that block **only after committing** the chunk's decoded records, rejected-record diagnostics and cursor locally. Do not reconstruct that block from decoded timestamps. Maintain arrival order where duplicate handling and cursor association depend on it.
+
+A successful chunk acknowledgement can allow the strap to reclaim history. Local storage failure therefore withholds it. An end marker closes a chunk; transfer-complete closes the overall session. Timeouts do not authorize acknowledging an uncommitted open chunk. NOOP's watchdog, retries and backlog handling are client policies; cross-disconnect replay guarantees remain unknown; local retry and cursor roles are specified below. ABORT_HISTORICAL_TRANSMITS is a stop request, not a trim, and local cleanup must not depend on a reply arriving. FORCE_TRIM and SET_READ_POINTER are separate invasive cursor mutations and are not substitutes for normal chunk acknowledgement.
+
+## Scheduled-control timing
+
+The [high-frequency sync scheduler](PROTOCOL_COMMANDS.md#high-frequency-sync-scheduler)
+uses wall-clock seconds for duration and a separate callback counter for its period.
+It schedules events; it is not a demonstrated Bluetooth throughput control. Command
+96/97 and events 96/97/98 occupy separate namespaces.
+
+[Alarms](PROTOCOL_ALARMS.md) likewise compare whole wall-clock seconds for due status.
+Clock validity, stored configuration, command acknowledgement and eventual physical
+execution are distinct. Preserve the pending/final RUN response sequence and do not
+assume that manual RUN leaves the saved schedule intact.
+
+
+## Complete response bodies
+
+Offsets in the following tables begin after command, origin sequence and result. Lengths exclude framing, CRC and padding. All integers are little-endian. Pending is not completion.
+
+## Battery level — command 26
+
+The final response body is **four bytes**, an unsigned whole-percent value.
+The fractional part is discarded. A successful response carries result 1;
+a nonzero error on the ordinary completion callback carries result 0 and four zero bytes. Actual measurement timeout/error handling follows a different continuation and does not guarantee that reply. A zero value
+can also be a conversion fallback, so it does not by itself prove a depleted
+battery. Do not interpret this version's logical body as a one-byte response.
+
+## Hello — command 145
+
+Send revision 1 or 3. Initial PENDING has a zero-filled body except the revision:
+107 bytes for revision 1, 111 for revision 3. Unsupported request revisions return
+FAILURE with revision 1 and 106 zero bytes. Final supported responses have the
+following layout. Identity blocks can contain personal device information;
+applications generally need selected identity/version fields, not a raw body dump.
+
+| Offset | Width | Field |
+|---:|---:|---|
+| 0 | 1 | Revision 1 or 3 |
+| 1 | 4 | Battery, tenths of a percent |
+| 5 | 1 | Opaque state byte |
+| 6 | 4 | Unix seconds |
+| 10 | 4 | Fractional ticks, units 1/32768 second |
+| 14 | 11 | Identity text block A |
+| 25 | 24 | Opaque identity block |
+| 49 | 30 | Identity text block B |
+| 79 | 4 | Version-specific word, value 13 |
+| 83 | 4 | Opaque cached word |
+| 87 | 4 | Opaque state word |
+| 91 | 3 | Version components 50, 42, 1 |
+| 94 | 4 | Version suffix 0 |
+| 98 | 3 | Opaque three-byte field |
+| 101 | 1 | Profile/configuration value, 0–2 |
+| 102 | 1 | Opaque cached state byte |
+| 103 | 4 | Preparation status bits |
+| 107 | 4 | Revision 3 only: opaque cached state word |
+
+The final reply reports SUCCESS even if preparation status bits are set. Retain
+those bits and do not assume every field is valid merely because the result is 1.
+Preparation bit `0x10` marks the failed identity-block-A helper condition, and
+`0x20` marks the failed identity-block-B helper condition. Revision 3 can additionally
+set `0x40` from its extra preparation check; that check's full meaning remains
+unresolved. Other preparation bits must also be preserved.
+Revision 3 adds the trailing word and an additional preparation-status check.
+The fractional field occupies four bytes despite the underlying clock fraction
+having only 16 significant bits. Text blocks must be bounded by their field width.
+The legacy command 35 does not build a reply on its command handler path; do not
+use it as an interchangeable command-145 request.
+
+## Battery pack — command 151
+
+Send revision 1. The immediate response reports cached information, with SUCCESS
+even when no pack is present. Its body is 28 bytes. Other request revisions return
+FAILURE with revision 1 and 27 zero bytes.
+
+| Offset | Width | Field |
+|---:|---:|---|
+| 0 | 1 | Revision 1 |
+| 1 | 1 | Presence flag |
+| 2 | 6 | Address/identifier block |
+| 8 | 16 | Serial/text block; handle as bounded bytes |
+| 24 | 2 | Charge value; scale requires separate confirmation |
+| 26 | 1 | Opaque cached field |
+| 27 | 1 | Opaque cached field |
+
+A successful response is not a live query or a freshness guarantee.
+
+## Data range — command 34
+
+An empty PENDING body precedes completion. Final SUCCESS contains revision 1
+followed by 16 unsigned 32-bit fields, totalling **65 bytes**. Failure, including
+the response timeout path, returns revision 1 followed by 64 zero bytes.
+
+| Offset | Width | Field |
+|---:|---:|---|
+| 0 | 1 | Revision 1 |
+| 1 | 4 | Ring boundary A; full oldest/erase role unresolved |
+| 5 | 4 | Read-page cursor B |
+| 9 | 4 | Write-page cursor C |
+| 13 | 4 | Acknowledged/trim boundary D |
+| 17 | 4 | Write-wrap state/count |
+| 21 | 4 | Ring capacity in page slots |
+| 25 | 4 | Record-count estimate; may fall back to 15 records per page |
+| 29 | 4 | Derived quantity: 15 × (adjusted D + capacity − adjusted C) |
+| 33, 37 | 4 each | Clock pair from boundary-A page |
+| 41, 45 | 4 each | Clock pair from trim-side page, preceding page if equal to write |
+| 49, 53 | 4 each | Clock pair from read-side page, preceding page if equal to write |
+| 57, 61 | 4 each | Clock pair from last-written page |
+
+For the derived quantity, C and D are each increased by capacity if below A.
+The record-count estimate is zero when adjusted trim and write positions coincide.
+On the successful record-sequence extraction path, it is the greater of the
+unsigned 32-bit expression `currentSequence − extractedSequence + 1` and the page
+distance. Other paths use fallback estimates, so this is not an exact record count
+or an all-path formula.
+For accepted format-1 records, the first three clock pairs use the inner record
+clock for packet 47 and the header clock for packets 48 and 54. Unsupported
+header/type combinations yield a zero pair. The last pair prefers the page's
+stored final clock and uses a compatible record/header fallback only when both
+stored components are zero. These fallbacks do not establish valid dates.
+
+The second member of each pair is a 16-bit value widened to 32 bits. Boundary A and the complete units of every retained clock form remain unresolved. Treat the estimates as counts/distances, not durations. Failed page reads produce seconds-like 0xffffffff and widened companion 65535; unsupported headers can produce zero pairs. The history contract below specifies local retries and current type-49 routing without a universal cross-version guarantee.
+
+
+### Historical synchronization: boundaries, retries and range interpretation
+
+a history command response is an acceptance
+result, separate from the history stream. START opens the stream, END marks a
+consumer acknowledgement boundary, and COMPLETE closes the current attempt.
+Completion alone is insufficient to assert that every stored record has reached
+persistent application storage. Use saved progress and the available range to
+decide whether another bounded attempt is useful.
+
+The current START, END and COMPLETE messages use metadata packet type 49.
+This establishes the current implementation's particular history path, not a
+universal firmware boundary between types 49 and 56. Keep support for other metadata
+routes where independently required by supported-device evidence.
+
+An END token contains eight bytes. Keep and echo the entire original token after
+persisting both the completed chunk and its local progress marker. The first word
+is a read-page position modulo ring capacity; the second reflects write-wrap state.
+The ordinary acknowledgement path uses the first word for its boundary operation,
+but this does not make the trailing bytes optional. Do not construct a replacement
+token from an independently saved cursor.
+
+The ordinary boundary operation maps the supplied first word into the ring
+geometry, handles boundary crossing and reduces the selected position modulo
+capacity before updating the acknowledged/trim boundary. When write-wrap state
+is zero, an ahead-of-write target is clamped to the write position. This does
+not change the requirement to echo the original END token unchanged.
+
+Special tokens are separate from ordinary chunk acknowledgements. With a first
+word of `0xffffffff`, the storage handler skips the normal boundary operation but
+still signals completion. A pair of `0xfefefefe` words enables a special mode and
+arms its timer; the current write-page position replaces the normal boundary
+input. A pair of `0xfdfdfdfd` words clears that mode and invokes a separate cursor-restoration path using a
+stored history boundary before the subsequent boundary operation. That boundary
+is distinct from the acknowledged/trim boundary; its complete retention role is
+unresolved. While the special
+mode is set, an ordinary pair also uses the current write position. These are
+wire-reachable control cases, not replacement tokens to synthesize for a committed
+chunk, nor a validated recovery or erase procedure.
+
+The selected history sender forwards positive, already-framed records only up to
+2140 bytes. Zero is handled separately. This is a record-forwarding bound, not the
+BLE MTU or a maximum for every protocol packet.
+
+The device has bounded local retries. Its END-wait timeout path resends END on
+its first four expirations and changes state on the fifth. The repeated END may
+have a new clock value while preserving its token. Non-success acknowledgements
+use a separate counter and do not perform the normal successful boundary operation;
+the fifth such outcome changes a local transfer limit. Neither branch provides an
+exactly-once delivery promise across disconnects. The application should tolerate
+repeated boundaries and records, retain arrival order, and stop an incomplete
+attempt without acknowledging data it could not persist.
+
+The read cursor and the acknowledged/trim boundary are separate. A backing-page
+read error can advance the read cursor and return an error. That observation does
+not prove physical deletion or advancement of the acknowledged boundary. It does
+mean the client must not assume that the very next read automatically retries the
+same failed page. Preserve decode/read failures as part of synchronization evidence.
+
+The extended range response contains page positions, capacity, estimates and four
+clock pairs. Read and write positions are page slots, not timestamps. The selected
+ring uses 4096-byte page slots, which can contain multiple records. The count
+estimate can fall back to fifteen records per page, so it should not be treated as
+an exact number of application rows or a duration. The clock pairs are selected
+from a boundary page, the trim-side page, the read-side page, and the last written
+page. Empty-side cases can select the preceding page. Failed reads use an all-ones
+six-byte sentinel; its two-byte companion becomes 65535 when widened for the
+response. Keep sentinel and zero fallbacks separate from usable time values.
+
+### Battery replies and cached accessory information
+
+The normal battery request schedules a measurement before returning its percentage.
+The physical measurement's error/timeout event takes a preparation continuation
+that differs from the normal percentage callback. Therefore the ordinary callback's
+failure-body layout does not establish a guaranteed battery reply after every
+measurement timeout. Correlate the requested command and apply a bounded client wait.
+
+Battery-pack information is cached. Both the full accessory record and its smaller
+charge update copy received values without local scale conversion. The strap-side message contract alone does not independently establish a physical percentage
+scale for that raw charge value. Preserve the raw value and apply only a separately
+verified interpretation. A successful cache query is not proof of a live accessory
+measurement or freshness.
+
+### Interruption and recovery
+
+a fresh history preparation restores the read position to the
+acknowledged boundary when all stored ring positions are valid. Previously
+received but unacknowledged records can therefore recur. Preparation failure can
+also enter streaming, so START does not prove that restoration succeeded.
+
+Connection loss exits history preparation, streaming or END wait and cancels their
+transfer timers and subscriptions. Leaving history can itself queue COMPLETE when
+the cached backlog is at most six; queueing proves neither delivery nor that all
+source records were acknowledged. Reconcile completion metadata with persisted
+progress and backlog. On the fifth END-wait timeout the attempt leaves history;
+a later preparation performs the rewind, rather than that timeout immediately
+restarting it. Earlier timeout retries resend END in the existing wait.
+
+Persist data before ACK, retain the complete original token and tolerate duplicate
+records. After reconnect, restore the application's subscriptions and reconcile
+requested collection with actual output; automatic restoration of every sensor
+request is not established.
+
+Connection loss, a Bluetooth controller restart and full application startup are
+distinct operations. Controller restart does not prove sensor session requests
+were initialized again. On the successful application-start path, sensor
+initialization clears temporary
+collection requests and staged/live state. Later policy evaluation can reassert
+persistent or continuous collection preferences. This startup initialization does
+not establish that every reset command completes that path.
+Temporary silence does not prove acquisition or history
+recording stopped. Track persistent collection preferences separately from
+temporary collection requests.
+
+Connection establishment is not an acknowledgement that ECG or sensor sessions
+were restored. Avoid blindly resending ECG start; [repeated starts](PROTOCOL_ECG.md#repeated-ecg-start-and-companion-collection)
+can lose the bookkeeping used for companion collection cleanup.
+
+## Connection and error recovery boundaries
+
+Connection establishment is not a sensor-session restoration acknowledgement.
+Selected connection handling cancels a connection-related timer and finishes
+internal duration measurements; it does not establish that the app's previous
+ECG or raw-sensor requests have been restored.
+
+Diagnostic records are best-effort evidence of lifecycle activity. A record can
+be created and submitted internally but rejected later when the storage service's
+queue has no free slot. Missing diagnostic records therefore do not prove that
+an error or connection transition did not occur, and submission does not certify
+durable storage.
+
+Error handling is conditional. The selected recovery policy distinguishes its
+error reason from the ordinary link-loss reason, checks a matching occurrence
+count, checks pending-state guards and stored bookkeeping, and schedules deferred
+storage coordination. Do not treat every disconnect as an application reset or
+as a request that clears all sensor sessions. Neither recovery scheduling nor a
+new connection certifies acquisition state, completed reboot or durable storage.
+
+A guarded internal recovery sequence coordinates with storage and then enters a
+fault-handling path after deferred steps. This sequence provides no application
+acknowledgement guaranteeing that storage completed, a restart succeeded, or a
+previous sensor session returned. It is not the generic behavior of ordinary
+link loss. Clearing a related failure indicator does not, in the corresponding
+handlers, cancel an already pending recovery sequence.
diff --git a/docs/PROTOCOL_UPDATES.md b/docs/PROTOCOL_UPDATES.md
new file mode 100644
index 0000000000..b39af15849
--- /dev/null
+++ b/docs/PROTOCOL_UPDATES.md
@@ -0,0 +1,119 @@
+# Firmware updates and device authorization
+
+This chapter follows the [shared scope](PROTOCOL.md#scope-and-compatibility). Image integrity, boot acceptance and session authorization are distinct mechanisms. These contracts describe boundaries, not a validated installation procedure.
+
+## Image-transfer command boundaries
+
+| Command | Request body | Response and limits |
+|---:|---|---|
+| 83 | `revision:u8=1` | Starts incremental image verification. Immediate completion/failure has body `[1]`; unfinished verification continues asynchronously. The final response retains the original request correlation, body `[1]`, and outer result 1 for integrity success or 0 for failure. |
+| 142 | `revision:u8=1` | `[1, detail]`; success detail 0, preparation failure detail 10. |
+| 143 | `revision:u8=1, offset:u32le, length:u8, data[length]` | Length at most 224. `[1, detail]`; success detail 0, otherwise a lower-layer error or preparation detail 10. Partition, alignment and storage constraints also apply. |
+| 144 | `revision:u8=1` | Image integrity acceptance returns `[1,1]` with result 1 and queues lifecycle work. Preparation, integrity or revision failure returns `[1,0]` with result 0. Later boot acceptance remains a separate boundary. |
+
+An incomplete successful command 83 verification step schedules another step without
+sending a final response. The normal continuation needs no additional client
+command. Failure to close update storage does not replace the saved integrity
+result. Timeout, disconnection and overlapping requests remain unresolved; wait
+for the correlated result and do not treat silence as success.
+
+Command 144 success confirms the application image CRC gate and requests storage
+coordination plus a delayed board reset. Those requests do not establish completed
+reset, installation, authenticity or boot acceptance. A reconnect does not by
+itself identify the accepted image. Signature, compression and rollback policies
+remain unspecified; CRC equality and version fields do not establish them.
+These contracts do not supply an installation sequence.
+
+## Image container and integrity fields
+
+The documented container format has a **512-byte header** followed by its
+payload. Offsets below are offsets within the file, not Bluetooth-frame or memory
+addresses. Multibyte numeric fields are little-endian.
+
+| File offset | Width | Meaning |
+|---:|---:|---|
+| 0 | 4 | CRC32 of all payload bytes starting at offset 512; this is not a magic value |
+| 4 | 4 | Payload length, excluding the 512-byte header |
+| 8 | 4 | Unresolved header word |
+| 12 | 4 | Image-type selector |
+| 16 | 4 | Unresolved header word |
+| 504 | 4 | Header CRC32 over bytes 8–503 inclusive |
+
+Both checks use the conventional CRC32 result format used by `zlib.crc32`.
+Unlisted header bytes include version/build information and unresolved fields;
+this table does not define them as zero or freely editable. The payload CRC and
+length are outside the header-CRC range.
+
+The compressed `.zbin` container and its decompressed image each carry their own
+header and payload checks. In the compared artifacts, type 5 carries a gzip
+payload and type 1 carries the decompressed payload. This is a bounded type
+mapping, not a complete type enumeration. Do not substitute one representation's
+length or checksums for the other's.
+
+The application checks partition bounds and payload integrity. Chunk writes of
+the documented size are read back and compared; that comparison is separate from
+whole-image verification and eventual boot acceptance. These fields support
+container inspection; they do not establish which representation a complete
+installation procedure must transfer or that a modified container will boot.
+
+## Certificate command boundaries
+
+| Command | Request body | Response and limits |
+|---:|---|---|
+| 155 | `revision:u8=1` | `[1]`; starts a fresh certificate transfer. |
+| 156 | `revision:u8=1, offset:u16le, length:u8, data[length]` | `[1]`; chunks are at most 225 bytes and the transfer capacity is 2458 bytes. Use nonwrapping, in-range slices. An immediately repeated offset is acknowledged without comparing replacement content. |
+| 157 | `revision:u8=1, declared_total:u16le` | `[1, detail]`: 1 validation success, 2 validation failure, 3 accumulated/declared length mismatch, 0 unsupported revision. Outer result is 1 only for validation success. |
+| 158 | `revision:u8=1` | `[1]`; revalidates and processes the certificate. Result 1 reports processing success. Certificate and metadata storage are separate operations, so failure does not promise that persistent state is unchanged. |
+| 159 | `revision:u8=1` | `[1]`; acknowledges a queued BLE authorization lock. Certificate clearing depends on the prior authorization state; eventual storage success is not reported. |
+
+Accumulated transfer length does not prove contiguous byte coverage. The certificate
+has three nonempty, period-separated segments. Payload and signature use unpadded
+URL-safe Base64. Verification covers the original encoded first and second
+segments and their separating period; the uploaded payload does not supply the
+trust key. Complete header-validation and issuer-provisioning policies remain
+unspecified.
+
+Certificate verification uses SHA-256 with the P-256 signature
+operation. The decoded signature is exactly 64 bytes: a 32-byte `r` followed by a
+32-byte `s`, rather than an ASN.1 DER signature. Payload and signature decoding
+each have a 512-byte output bound; the signature must also satisfy its exact
+64-byte length. This algorithm contract does not establish complete cryptographic
+implementation validation or firmware-image authentication and rollback policy.
+
+Required claims are strings `aud` and `sub`, bounded to 30 and 11 bytes and compared
+to stored device identity fields, plus decimal unsigned 32-bit numeric `iat` and
+`exp`, with `exp >= iat`. New transfers require `iat` to be strictly greater than the stored accepted value. If reading
+that metadata fails, the comparison uses a zero-filled fallback instead; a read
+failure does not itself force rejection. Identity-storage reads also initialize
+buffers and continue to the identity comparisons after read errors; those errors
+do not independently force certificate rejection. The separate remaining authorization duration is derived
+from `exp - iat`; it is not the freshness value or an established direct wall-clock
+comparison against `exp`. One accounting path charges elapsed monotonic seconds
+during flash work, capped at the remaining amount. A successful write commits the
+reduced amount and advances that accounting timestamp; failure behavior and reboot
+restoration remain separate limits. A saved certificate is parsed during cold initialization
+without requiring its
+own saved `iat` to be newer than itself. That differs from accepting a new
+transfer. JSON edge cases and complete duration restoration after reboot remain
+unresolved.
+
+Command 158 revalidates, stores the certificate, updates accepted `iat`, requests
+the remaining duration and queues an unlocked-state update. Storage operations
+are separate and failure may follow a partial persistent change. Equal `exp` and
+`iat` provide zero duration, so processing success does not guarantee a lasting
+unlocked state.
+
+Command 159 queues an authorization lock. Applied to a previously unlocked state,
+it also attempts to clear the stored certificate and requests zero duration;
+already locked, it skips that clearing step. Its response does not report the
+later storage result. This behavior does not establish irreversible fuse
+programming. A later properly signed, identity-matching certificate that processes
+successfully can request an unlocked state again; the persistent freshness
+requirement still applies when its metadata is readable. This is not a tested
+recovery procedure or a source of issuer authorization.
+
+A transfer acknowledgement, validation, storage, authorization update and lock
+completion are distinct outcomes. No installation or lock/recovery operation has
+been validated on a device for these contracts.
+
+For sensor production, live/save policy, typed configuration and flag behavior use [configuration](PROTOCOL_CONFIGURATION.md); for ECG wrist/start/stop and independent raw/filtered routing use [ECG](PROTOCOL_ECG.md). Those pages separate requested state from applied state and packet delivery. Exact timing, energy cost, all reset paths and all hardware variants remain open unless a specific contract says otherwise.
diff --git a/docs/PROTOCOL_WHOOP4.md b/docs/PROTOCOL_WHOOP4.md
new file mode 100644
index 0000000000..58caebd04d
--- /dev/null
+++ b/docs/PROTOCOL_WHOOP4.md
@@ -0,0 +1,158 @@
+# WHOOP 4 profile
+
+Read the [scope and compatibility](PROTOCOL.md#scope-and-compatibility) before applying this page.
+
+## WHOOP 4.0 — service `61080001-…`
+
+Defined in `BLEManager.swift` (the on-device, authoritative UUIDs) and mirrored as plain
+strings in `DeviceFamily.swift`. The same `Strand/BLE/` sources (`BLEManager`,
+`StandardHeartRate`, `FrameRouter`) back both Apple-platform targets — macOS and iOS.
+
+| Role | UUID | Direction |
+|------|------|-----------|
+| Custom service | `61080001-8d6d-82b8-614a-1c8cb0f8dcc6` | — |
+| Command write (`cmdWriteChar`) | `61080002-8d6d-82b8-614a-1c8cb0f8dcc6` | app → strap |
+| Command-response notify (`cmdNotifyChar`) | `61080003-8d6d-82b8-614a-1c8cb0f8dcc6` | strap → app |
+| Event notify (`eventNotifyChar`) | `61080004-8d6d-82b8-614a-1c8cb0f8dcc6` | strap → app |
+| Data notify (`dataNotifyChar`, fragmented) | `61080005-8d6d-82b8-614a-1c8cb0f8dcc6` | strap → app |
+
+
+
+## WHOOP 4.0 envelope
+
+```
+┌──────┬───────────────┬───────┬───────────── inner ─────────────┬─────────────┐
+│ 0xAA │ length u16 LE │ crc8 │ type │ seq │ cmd │ payload … │ crc32 u32 LE│
+│ [0] │ [1..3) │ [3] │ [4] │ [5] │ [6] │ [7 .. len) │ [len .. +4) │
+└──────┴───────────────┴───────┴───────────────────────────────────┴────────────┘
+total frame size = length + 4
+```
+
+- **`0xAA`** — Start Of Frame.
+- **`length`** — `u16` little-endian. Equals `inner.count + 4` (the inner `[type][seq][cmd]
+ payload]` plus the 4 envelope bytes). It is the offset at which the CRC32 trailer begins.
+- **`crc8`** — CRC8 (table-driven, poly `0x07`) computed over the **two length bytes only**
+ (`crc8([frame[1], frame[2]])`).
+- **inner record** — `type` (packet type), `seq` (sequence / version byte), `cmd`
+ (command number), then the payload.
+- **`crc32`** — standard zlib CRC-32 (reflected, poly `0xEDB88320`), `u32` little-endian,
+ computed over the **inner bytes** `frame[4 .. length)`.
+
+Reference: `verifyFrame(_:)` and `crc8(_:)` / `crc32(_:)` in `Framing.swift`, and the
+outbound builder `WhoopCommand.frame(seq:payload:)` in `Strand/BLE/Commands.swift`.
+
+```swift
+// Framing.swift — WHOOP 4.0 validation (abridged)
+let length = u16le(frame, 1)
+let crc8OK = crc8([frame[1], frame[2]]) == frame[3]
+if 7 <= length && length + 4 <= frame.count {
+ let inner = Array(frame[4..
+
+## Bond handshake & connect lifecycle (WHOOP 4.0)
+
+This section records NOOP’s WHOOP 4 connection sequence and its observations. The delays and periodic timers are client policy, not required protocol timing. NOOP marks its WHOOP 4 connection as bonded after the confirmed command write is
+acknowledged, then runs its connection handshake. This describes client connection
+handling; a write acknowledgement is not independent proof of a persistent OS bond.
+
+```
+scan(service 61080001) ─▶ connect ─▶ discoverServices
+ └▶ discoverCharacteristics
+ ├ on cmdWriteChar (0002):
+ │ confirmed write GET_BATTERY_LEVEL ── THE BOND TRICK
+ └ on 0003/0004/0005/2A37/2A19: setNotifyValue(true)
+ confirmed-write ack (didWriteValueFor, no error) ─▶ BONDED (state.bonded = true)
+```
+
+After bonding, the connect handshake runs **exactly once** per connection (guarded by
+`connectHandshakeDone`, because `didWriteValueFor` re-fires on every later `.withResponse`
+write). Re-blasting the handshake mid-offload was the historical root cause of the strap
+refusing to stream type-47, so the guard is load-bearing. The one-shot handshake (in
+`peripheral(_:didWriteValueFor:error:)`) issues, in order:
+
+1. `GET_HELLO_HARVARD` (35) — version/identity hello (mirrors the official flow; not strictly
+ required to serve).
+2. `GET_ADVERTISING_NAME_HARVARD` (76).
+3. `SET_CLOCK` (10) — the client sends both retained variants with the same Unix
+ seconds: four seconds bytes followed by four zeros, then four seconds bytes
+ followed by five zeros. These are WHOOP 4 compatibility attempts.
+4. `GET_CLOCK` (11) — the client tries both an empty body and `00`. Which form
+ responds or updates the clock depends on the supported WHOOP 4 firmware.
+ Earlier investigations reported unsuitable bodies leaving the clock unchanged,
+ including cases with an acknowledgement. Read back the clock: ACK alone does
+ not prove it latched, and silent history does not uniquely identify a clock problem.
+5. `SEND_R10_R11_REALTIME` (63) with `[0x00]` — stop the ~2/s type-43 raw flood (BLE airtime /
+ battery / flash). This is the *real* control for that stream; `STOP_RAW_DATA` (82) does not
+ affect it.
+6. `GET_DATA_RANGE` (34) — refresh the strap's stored record range for the liveness watchdog.
+7. After ~1.5 s (so the link settles), the first historical offload via `requestSync(.connect)`.
+
+A periodic backfill timer (`backfillIntervalSeconds = 900`, i.e. 15 min, matching WHOOP) and a
+keep-alive timer (`keepAliveIntervalSeconds = 30`: re-arm realtime, poll battery, watchdog the
+link) are then started. The `GET_CLOCK` response is decoded by `ClockCorrelation` to produce a
+`ClockRef(device:wall:)` for realtime decoding. Backfill can also proceed with
+the client's identity-clock fallback when correlation is unavailable.
+
+> WHOOP 5.0 instead writes the static `CLIENT_HELLO` [frame](PROTOCOL_WHOOP5.md#connection-and-frame-format) to its `…0002` command
+> characteristic immediately after discovery.
+
+---
+
+
+
+## `HISTORY_END` payload layout
+
+The following offset table is the historical WHOOP 4 decoder convention. WHOOP 5/MG has a separate [history contract](PROTOCOL_TRANSPORT.md#historical-synchronization-boundaries-retries-and-range-interpretation); do not reuse these offsets for it.
+
+The `metadata` post-hook decodes the payload (which begins at `frame[7]`, after `[type][seq]
+[cmd]`) as `struct '
+
+## WHOOP 5.0 vs MG — telling the hardware apart
+
+Both labels share the `fd4b…` GATT family and the same puffin envelope: the shared framing and parser family is represented by `DeviceFamily.whoop5`. Hardware capabilities and record availability still need to be checked separately. What differs is hardware —
+an MG carries the ECG-conductive clasp, a 5.0 does not.
+
+NOOP's `Whoop5Variant` resolver uses the standard BLE Device Information Service,
+separately from `DeviceFamily`. Its current identification policy is listed below;
+these matching rules do not change frame parsing.
+
+| Signal | DIS characteristic | Reads |
+|---|---|---|
+| Model number `MG` (case-insensitive, surrounding whitespace ignored) | Model Number String (`0x2A24`) | MG; takes priority over the fallback signals below |
+| Serial prefix `5AM` | Serial Number String (`0x2A25`) | MG |
+| Serial prefix `5AG` | Serial Number String (`0x2A25`) | 5.0 |
+| Hardware revision contains `WG50` | Hardware Revision String (`0x2A27`) | 5.0 |
+
+Without an explicit `MG` model number, conflicting `5AM` serial and `WG50` hardware
+signals resolve to `.unknown`. Unknown variants do not enable MG-only features.
+An MG has also been observed with a different serial prefix and hardware revision;
+there is no universal MG hardware-revision token in this resolver. Absence of a
+recognized prefix therefore does not establish that the strap lacks MG hardware.
+
+## Connection and frame format
+
+Subscribe to the fd4b notification channels and write the static client hello below to the command characteristic with response. WHOOP 4’s bond/hello sequence is a separate profile. Framing, padding, response correlation and recovery are defined once in [transport](PROTOCOL_TRANSPORT.md).
+
+```text
+AA 01 08 00 00 01 E6 71 23 01 91 01 36 3E 5C 8D
+```
+
+## Operations and measurements
+
+Use [commands](PROTOCOL_COMMANDS.md), [configuration](PROTOCOL_CONFIGURATION.md) and [sensor records](PROTOCOL_SENSORS.md) together. Packet type, record layout and inner version are different selectors; the receiver must decode what was emitted. MG [ECG](PROTOCOL_ECG.md) requires the appropriate hardware capability. A successful request alone does not prove sensor initialization or delivered data.
diff --git a/docs/RAW_DATA_CAPTURE.md b/docs/RAW_DATA_CAPTURE.md
index e03115f6c4..6185411ddf 100644
--- a/docs/RAW_DATA_CAPTURE.md
+++ b/docs/RAW_DATA_CAPTURE.md
@@ -31,10 +31,12 @@ The writes use the authenticated WHOOP command characteristic and require a conn
An accepted command is not evidence that samples arrived, so the collector reports connection state,
request state, packet/byte counts, the last packet time, and history-sync progress separately.
-The decoded IMU buffer contains 100 signed 16-bit samples for each of `ax, ay, az, gx, gy, gz`, keyed
-by a strap Unix timestamp. Accelerometer scale is `1/4096 g/LSB`; gyroscope scale is
-`0.06104 deg/s/LSB`. See [BLE reverse engineering](BLE_REVERSE_ENGINEERING.md#4-the-realtime-r10r11-raw-stream-type-43)
-for the byte layout and validation evidence.
+The earlier NOOP decoder accepts 100 signed 16-bit samples for each of
+`ax, ay, az, gx, gy, gz`, keyed by a strap Unix timestamp, and applies
+`1/4096 g/LSB` and `0.06104 deg/s/LSB` as its historical scaling convention.
+The [WHOOP 5/MG R21 layout](PROTOCOL_SENSORS.md#r21-six-axis-imu) also permits
+partly populated buffers; its fixed layout does not independently prove physical
+scaling or timing for every configuration.
## Live capture and history repair
@@ -109,18 +111,7 @@ suggested archive name is `noop-5mg-raw-.zip`.
## Scope and operational limits
-- Capture is deliberately bounded and explicit. One uncontrolled WHOOP 5/MG discharge trace provides
- a useful order of magnitude, not a benchmark: a 5 h 47 min overnight capture consumed about
- `0.71 percentage points/hour`, and two later 42–45 minute captures each consumed about
- `0.57 percentage points/hour`. Nearby non-capture periods in the same discharge averaged about
- `0.31 percentage points/hour`; a separate 5.6-day pre-capture baseline averaged about
- `0.46 percentage points/hour`. These single-band observations suggest roughly 1.5–2.3× the normal
- drain while 100 Hz is active, depending on the chosen baseline. They do **not** establish a general
- runtime guarantee or isolate producer, BLE, and history-repair costs.
-- The current evidence does **not** establish flash-retention, thermal, or BLE-airtime costs for
- continuous 24/7 100 Hz operation.
-- A one-hour workout/research capture succeeding does not establish that a 36-hour rolling recorder is
- safe. Any future rolling buffer needs hardware measurements and an explicit retention policy.
+
- The separately enabled protocol trace remains a general diagnostics tool. Starting a Raw Data
Collector session does not enable it or duplicate its transport frames into the raw outbox.
- Session capture has one source of truth for high-rate motion: its file-backed `.imus` segments.
diff --git a/docs/WHOOP5_DEEP_DATA.md b/docs/WHOOP5_DEEP_DATA.md
index 8c02c0c96f..d1143810db 100644
--- a/docs/WHOOP5_DEEP_DATA.md
+++ b/docs/WHOOP5_DEEP_DATA.md
@@ -1,42 +1,37 @@
-# WHOOP 5.0 / MG deep data — the "R22" unlock
+# WHOOP 5.0 / MG deep-data experiment and historical observations
-**Status:** experimental, opt-in. Deep-history delivery and v20/v21/v26 structural layouts are
-confirmed on hardware; wavelength identity and product-grade optical ingestion remain open.
-**Tracking:** [#103](https://github.com/ryanbr/noop/issues/103) (raw HCI captures + new deep-record layouts).
-## The problem
+
+
-A WHOOP 5.0 / MG strap hands a freshly-connected third-party client **only live heart rate** (over the
-standard `0x2A37` profile, which needs no bond). Recovery, strain, sleep, motion and history don't come
-through. This is the single biggest gap in NOOP's 5/MG support, and it affects every independent WHOOP
-app equally.
+## Collection and output
-## Why — the feature-flag gate
+Historical type-47 delivery without configuration writes was observed in
+[goose #24](https://github.com/b-nnett/goose/issues/24). Collection, storage and
+packet output are separate controls; use the [configuration reference](PROTOCOL_CONFIGURATION.md)
+for the current contract and per-key polarity. R22 has an [inner record version](PROTOCOL_SENSORS.md#r22-inner-version),
+not a hardware revision. Sensor identity and physiological interpretation remain
+separate from structural decoding.
-The official app switches on the deeper streams by writing a short burst of **persistent feature-flag
-config values** to the strap right after the hello handshake. The most load-bearing of these is
-`enable_r22_packets`; "R22" is the strap's **optical/PPG data-product packet format** (versions v1–v8),
-not a hardware revision. Until those flags are set, the strap keeps the deep streams to itself.
+
+
-This was reached independently three ways, which is why we trust it:
+## Connection and framing
-| Source | Method | What it gives |
-|---|---|---|
-| [judes.club — "Cracking the WHOOP 5 Bluetooth Protocol"](https://judes.club/writing/cracking-the-whoop-5-bluetooth-protocol/) + [interactive spec](https://judes.club/experiments/whoop5/) | iOS HCI capture of the official app | The full frame format + the exact 15-flag enable sequence **with values**. Our `Whoop5Config` golden test is validated byte-for-byte against its frame-builder. |
-| [Asherlc/dofek](https://github.com/Asherlc/dofek/blob/main/docs/whoop-ble-protocol.md) | Android APK decompilation | The config opcodes (`0x73 START_DEVICE_CONFIG_KEY_EXCHANGE`, `0x78 SET_FF_VALUE`) and the same key names/values. |
-| A community BTSnoop capture ([#103](https://github.com/ryanbr/noop/issues/103)) | Bluetooth HCI log of the official app on a real strap | Independently surfaced the same `enable_r22_*` console report + the channel layout. |
+Use the [WHOOP 5 connection profile](PROTOCOL_WHOOP5.md) and
+[format-1 framing](PROTOCOL_TRANSPORT.md#format-1-framing).
-## Channel layout (5.0 / MG)
+
+
-| Channel (UUID suffix on `fd4b0001-…`) | Direction | Carries | NOOP |
-|---|---|---|---|
-| `0x2A37` standard HR | strap → app | live heart rate | subscribed ✅ |
-| `fd4b0002` | app → strap | `0xAA`-framed commands | writes here ✅ |
-| `fd4b0003/4/5/7` | strap → app | `0xAA`-framed responses + data + console | subscribes to all four ✅ |
+## Configuration interface
-NOOP already writes commands **and** subscribes to every data channel. So the blocker is not that NOOP
-isn't listening — the strap simply doesn't *start* the deep streams for a session that hasn't set the
-flags.
+Use the complete [named configuration body](PROTOCOL_CONFIGURATION.md#named-configuration-interface)
+and [feature inventory](PROTOCOL_CONFIGURATION.md#feature-flag-inventory).
+The older NOOP encoder uses a 40-byte key/value block after its revision byte;
+that client implementation is not the current 65-byte semantic request contract.
+Acceptance of truncated bodies remains unresolved. This page supplies no bulk
+“unlock” recipe.
## Console record sequencing and text reassembly
@@ -60,179 +55,46 @@ the incorrect u16 `record_index`. This is a deliberate scope limit, not a
cross-platform parity claim. Update the Kotlin twin and matching wrap/header vectors before
including this correction in a cross-platform release.
-## The frame format
+## Existing public sources
-Commands use the maverick/puffin envelope NOOP already implements
-(`Framing.puffinCommandFrame` / `crc16Modbus` + `crc32`):
+These sources contributed earlier protocol observations and client work:
-```
-[0xAA][0x01][declLen u16 LE][field=0x0100][CRC16-MODBUS of the 6 header bytes]
- [inner: 0x23 type][seq][cmd][b3][payload…]
-[CRC32 of inner, u32 LE]
-```
-
-- **`b3` (4th inner byte)** matters: GET_HELLO / SET_CONFIG want `0x01`; GET_DATA_RANGE /
- SEND_HISTORICAL want `0x00`. NOOP carries `b3` as the first payload byte (so `sendHistoricalData`
- with `[0x00]` is correct).
-- **Write WITH RESPONSE** — write-no-response is silently dropped by the strap.
-
-## The enable sequence (`Whoop5Config`)
-
-One `SET_CONFIG` (cmd `0x78`) per flag; the 40-byte body is the flag name as ASCII NUL-padded to 32
-bytes, the value byte (an ASCII `'1'`/`'2'`) at offset 32, then 7 zeros. `SET_CONFIG` is the sender
-enum's name for it on both platforms; the protocol schema calls 120 `SET_FF_VALUE`, so that is what a
-strap log shows when the strap answers one. Same opcode, two names. The exact ordered set, with
-values, is in [`Whoop5Config.swift`](../Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Config.swift)
-and [`Whoop5Config.kt`](../android/app/src/main/java/com/noop/protocol/Whoop5Config.kt), golden-tested on
-both platforms. `enable_r22_packets` is the one that opens the type-`0x2F` biometric stream; the rest
-tune channel selection, wear detection and sleep behaviour. Flags 1–15 come from judes.club's
-frame-builder; the 16th, `enable_sig12`, was added from a real on-strap HCI capture ([#103](https://github.com/ryanbr/noop/issues/103))
-that otherwise reproduced flags 1–15 byte-for-byte in this order.
-
-## How NOOP uses it (opt-in, reversible)
-
-- A **default-off** Settings → Experimental toggle, separate from the read-only probes because this one
- *writes* to the strap.
-- A manual **"Send enable sequence to strap"** button (not auto-run on connect), enabled only when a
- 5/MG is **bonded and worn** (the R22 stream is on-wrist gated).
-- The 16 flags are written with-response, ~80 ms apart.
-- It's **reversible** — it only changes which data the strap chooses to emit — and is the same thing the
- official app does on every connect.
-- **iOS / Android only on real hardware:** macOS CoreBluetooth can't complete the authenticated SMP bond
- the command characteristic requires, so the write path is unavailable on Mac.
+- [judes.club — Cracking the WHOOP 5 Bluetooth Protocol](https://judes.club/writing/cracking-the-whoop-5-bluetooth-protocol/)
+ and its [interactive specification](https://judes.club/experiments/whoop5/).
+- [Asherlc/dofek protocol notes](https://github.com/Asherlc/dofek/blob/main/docs/whoop-ble-protocol.md).
+- The community Bluetooth capture in [#103](https://github.com/ryanbr/noop/issues/103).
## High-rate IMU capture is a separate switch
-The R22 feature flags above govern deep-history products; they are not the missing step for an explicit
-100 Hz motion session. Hardware testing found a separate bounded raw-data sequence: `START_RAW_DATA`
-(81) must precede the two-byte 5/MG `TOGGLE_IMU_MODE` (106) selector. Sending 106 alone can return
-`SUCCESS` while producing no packets. With `[0x01, 0x01]` after command 81, NOOP receives and decodes
-the six-axis 100 Hz buffers; stop uses command 82 followed by `[0x01, 0x00]`.
-
-This establishes an on-demand research/workout capture path, not a safe continuous mode. Battery,
-retention, and BLE-airtime cost over day-scale recording remain unmeasured. The session collector,
-file-backed storage, Bluetooth-gap repair, and export contract are documented in
-[5/MG raw data capture](RAW_DATA_CAPTURE.md).
+NOOP’s raw IMU workflow uses command 81 followed by command 106 with `[1,1]`;
+stop uses command 82 followed by command 106 with `[1,0]`. Requests, effective
+collection state and packet delivery are separate; see the
+[collection controls](PROTOCOL_CONFIGURATION.md#collection-and-live-stream-coordination)
+and [R21 layout](PROTOCOL_SENSORS.md#r21-six-axis-imu).
## Honest limits
-- **No cloud scores.** Recovery/strain/sleep *scores* are computed in WHOOP's cloud and no public
- project has reproduced them. What the unlock buys is the **raw inputs** (high-rate HR, motion, fuller
- history) — which is exactly what NOOP needs, since NOOP computes its own scores on-device.
-- **It may not even be necessary.** [goose #24](https://github.com/b-nnett/goose/issues/24) shows a Gen5
- band streaming type-47 history to a third-party app *without* any config write. So the first thing to
- confirm is whether a clocked 5/MG already returns deep history through the plain
- `get_data_range`/`send_historical_data` loop NOOP already runs. If it does, the write path is belt-and-
- suspenders.
+- **Measurements are not product scores.** NOOP computes its own metrics from
+ the available records; decoded measurements do not reproduce WHOOP recovery,
+ strain or sleep scores by themselves.
- **The large records are no longer an undifferentiated type-`0x2F` blob.** Layout v21 (1,244 bytes)
contains six-axis IMU data; layout v20 (2,140 bytes) contains five repeated measurement blocks whose
- sensor identity remains open; layout v26 contains a 24-sample PPG waveform. The v20 blocks are
- preserved without optical/wavelength labels because the current capture does not prove what produced
- them, let alone red/IR identity.
-- **SpO₂ is not “one calibration away.”** The current v20 corpus has three active measurement blocks,
- but it has not established two separate red and infrared illumination measurements. See
- [`WHOOP5_OPTICAL_EXPERIMENT.md`](WHOOP5_OPTICAL_EXPERIMENT.md) for the passive controlled experiment
- that must precede reference-oximeter calibration.
-- **Blood pressure is not a decode target yet.** No hidden BP scalar has been identified. BP would
- require a validated model, reference-cuff data, population calibration, and an explicitly
- non-medical product boundary after the underlying optical/motion channels are established.
-
-## Why SpO₂ (and the raw respiration track) aren't available on 5.0
-
-This is the single most common "is it broken?" report (e.g. [#623](https://github.com/ryanbr/noop/issues/623)),
-so the reasoning in one place:
-
-**It is not an encryption problem.** NOOP decodes the entire 5.0 (v18) record in plaintext — HR, R-R,
-sleep, and the whole optical tail. Nothing on the wire is hidden behind a cipher NOOP would need a key
-for. The barrier is that the SpO₂ data simply isn't *in* the stream in a usable form:
-
-- **No *confirmed* SpO₂ field on the 5.0 wire — but there is now a candidate.** The raw optical tail
- (`@106` baseline, `@108/@109` amplitude pair, `@113` float) was checked against WHOOP-app SpO₂ across
- 18,602 real records — it does not match; those channels track HR/motion, and there is no identifiable
- red/IR pair. Pulse oximetry fundamentally needs two wavelengths; the 5.0's decodable stream doesn't
- expose them (the v26 PPG waveform is single-channel, HR only). However, a decompile-sourced decode
- ([#103](https://github.com/ryanbr/noop/issues/103)) reads v18 byte `@82` as a **strap-computed SpO₂ %
- scalar** (tri-mode: 70–100 = real %, bit-7 = saturation sentinel, other sub-70 = diagnostic code;
- sleep-only). The evidence is currently **split**: an 8-night independent validation with real spread
- (corr +0.99, ~0.4 %/night) clears the cross-night bar, but the two nights checked on the original #103
- capture device moved *opposite* to the app value — unresolved device/firmware variance or an extraction
- error on one side. NOOP therefore decodes `@82` as `spo2_candidate_82` (deep-timeline instrumentation
- only, in-band values only) so more devices can correlate it against the app's nightly SpO₂; it does
- **not** populate `spo2Pct` or any card/score until the contradiction is resolved.
-- **A calibrated % needs WHOOP's proprietary curve.** Even where raw optical exists, turning a red/IR
- ratio into a real SpO₂ % requires a device-specific calibration NOOP does not have — and NOOP will not
- fabricate one from unvalidated optical (the withdrawn #194 PPG→HR estimate is the cautionary
- precedent). `spo2Pct` is therefore nulled for *every* WHOOP; only an import writes it.
-
-**WHOOP 4.0 differs.** The 4.0 **v24** historical layout *does* bank raw SpO₂ channels (`spo2_red@68` /
-`spo2_ir@70`), so NOOP decodes the raw red/IR there (still not a calibrated %). The 5.0's v18 layout
-dropped those channels — most likely SpO₂ moved to a value computed on-device / in WHOOP's cloud rather
-than banked in the offload NOOP reads. NOOP reverse-engineers what the strap actually sends; if a
-decodable SpO₂ isn't sent, there is nothing to decode, plaintext or not.
-
-**Respiration is a partial exception.** The 5.0 sends no raw respiration ADC stream either (also
-4.0-v24-only), so the deep-timeline *track* is empty — but respiration is still estimated on-device from
-the R-R interval stream (RSA) and shown on the Health screen when enough overnight R-R is captured.
-
-**To see SpO₂ in NOOP on a 5.0:** import it. A WHOOP data export carries `blood_oxygen_pct`, and Health
-Connect import works too — both populate the Blood Oxygen card with WHOOP's own computed values.
-
-**Could it ever change?** Only via research, not decryption — and the `@82` candidate above is exactly
-that research in progress. What would flip it to a real reading: the `spo2_candidate_82` nightly values
-tracking the WHOOP app's own SpO₂ across many nights on **multiple devices** (a varying signal, not one
-coincidental match), including on the device where the two checked nights currently move opposite.
-Until that clears the bar, SpO₂ stays import-only on the 5.0.
-
-Wire-level facts (no SpO₂ opcode, export vs on-device aggregation, sleep-only product) are also summarised
-in [`PROTOCOL.md` §10](PROTOCOL.md#10-spo₂-on-50--mg--what-the-wire-does-and-does-not-carry). This section
-keeps the **promotion bar** and the harness that measures it.
-
-### `@82` validation checklist (what would promote the candidate)
-
-Only research — never a silent UI flip. A promote of `spo2_candidate_82` → `spo2Pct` needs all of:
-
-1. **Multiple devices / firmwares** (not one lucky strap): the nightly aggregate of in-band (70–100)
- `@82` samples during `sleep_state = asleep` tracks the official app or CSV `blood_oxygen_pct` with
- real night-to-night spread (not a flat 98 %).
-2. **Offset specificity:** nearby bytes (the 74–92 scan the harness already runs) must *not* track
- better than `@82`.
-3. **Incomplete nights:** when the export omits SpO₂, the wire candidate should be empty or
- out-of-band — not invent a number. This is a falsification test: an "always 97 %" decoder fails it.
-4. **Resolution of the #103 contradiction** on the original capture device (or a documented
- extraction / phase / duty-cycle error on one side).
-5. **No recovery / illness gating** on the candidate until (1)–(4) clear — same rule as other
- derived biosignals.
-
-The multi-device tool below implements the **measurable** half of this list: default gates include
-≥5 paired nights, export range ≥1 %, r ≥ 0.7, MAE ≤ 1.0, best offset = 82, in-band value variance,
-and duty-window coverage (with `feature_absent` when a long-enough asleep capture never emits `@82`).
-Points 4–5 stay human judgment on [#103](https://github.com/ryanbr/noop/issues/103).
-
-Until that bar is met, SpO₂ stays **import-only** on the 5.0, with `@82` available as instrumentation
-for owners who opt into deep-timeline / experimental logging.
-
-Related capability / UX roadmap: [#761](https://github.com/ryanbr/noop/issues/761) (honest labels when
-SpO₂ / skin temp / stages are unavailable vs experimental).
-
-### Band sleep flag vs hypnogram (quick reference)
-
-v18 byte `@81` high nibble is the strap's **coarse on-device sleep flag** (decoded as `sleep_state`):
-
-| High nibble | Name | Meaning |
-|------------:|------|---------|
-| 0 | wake | awake / active |
-| 1 | still | on-wrist still (not yet scored as sleep) |
-| 2 | asleep | scored night / sleep window |
-| 3 | up | post-sleep up |
-
-Useful for sleep *detection* and for gating sleep-only products (including SpO₂ candidates). It is
-**not** Light / SWS / REM — those stages are off-band. Full field notes live with the historical
-decode in `Interpreter.swift` / the Android twin.
-
-### Multi-device validation tool (`validate_spo2_candidate.py`)
-
-To make that bar concrete and privacy-preserving, `Tools/linux-capture/validate_spo2_candidate.py`
-turns one or more `(capture.json, WHOOP export)` pairs into a promote checklist:
+ producer is optical; layout v26 contains a [compact optical window](PROTOCOL_SENSORS.md#r26-compact-optical-window).
+ Optical producer identity does not establish wavelength labels or calibrated units.
+
+
+## SpO₂ and respiration interpretation limits
+
+The documented WHOOP 5 optical layouts do not establish calibrated SpO₂ or a raw
+respiration waveform. Keep R18 byte 82 uninterpreted; see the
+[sensor reference](PROTOCOL_SENSORS.md#r18-biometric-summary). NOOP’s candidate
+validation tools are research instrumentation, not a source of validated health metrics.
+
+### Comparison tool
+
+`Tools/linux-capture/validate_spo2_candidate.py` compares raw-byte observations
+with imported values. Its thresholds are research-tool policy, not proof of a
+physiological field or calibration.
```bash
cd Tools/linux-capture
@@ -240,45 +102,6 @@ python3 validate_spo2_candidate.py capture.json my_whoop_data/ --device strap-a
python3 validate_spo2_candidate.py --batch devices.json --postable
```
-Per device it computes the **nightly aggregate** of in-band `@82` samples (70–100) while
-`sleep_state = asleep`, pairs each night with CSV `blood_oxygen_pct`, then reports Pearson **r**,
-MAE, bias, and an **offset-specificity** scan over bytes 74–92 (only `@82` should win). Default
-gates: ≥5 paired nights, export range ≥1 %, r ≥ 0.7, MAE ≤ 1.0, best offset = 82, ≥5 distinct
-in-band values at `@82`, and ≥50 % duty-window coverage.
-
-**`@82` is duty-cycled**, which the harness has to account for or its numbers are meaningless.
-Across 18,650 v18 records — 18,602 from [@digitalerdude](https://github.com/digitalerdude)'s public
-PacketLogger capture of an official-app overnight sync, plus 48 from a NOOP sync — the byte is
-nonzero in 450 records (2.4 %), in 15 runs of *exactly* 30 records each, every run starting at the
-same `unix % 1200` with zero phase variance; outside the window it is identically `0x00`. A capture
-not aligned to that phase reads all zeros and is indistinguishable from a strap with the feature off
-— a plausible contributor to the split evidence above. The tool therefore **detects** the period,
-phase and window length per capture (never assuming the phase generalises across firmware),
-aggregates one value **per window** rather than per second, and reports per-night window coverage
-with a loud warning below the floor. A strap whose `@82` is flat `0x00` across a long enough capture
-is classified **`feature_absent`** — neither a PASS nor a FAIL in the multi-device gate.
-
-That absence claim is gated on the capture having actually **watched** the strap — long enough, and
-finely enough, that a duty-cycled feature would have fired somewhere the capture could see it. Both
-halves fail the same way if you get them wrong, reporting a working strap as lacking the feature:
-
-- **Long enough is observed time, not wall-clock span.** `max − min` counts the gaps, so a capture
- that ran densely for two minutes and then logged one record eight hours later scores an 8 h "span"
- off 121 s of observation. Sleep samples × cadence is what was watched, and that is what the bar uses.
-- **Finely enough is a nominal 30 s window.** Missing the window is a phase problem, not a duration
- one — a cadence sharing a large factor with the period only ever occupies `period ÷ gcd` residues,
- so at 300 s against 1200 s it either always lands inside the window or never does. Six nights of
- scored sleep then read a flat `0x00` off a perfectly healthy strap.
-
-A capture failing either test stays a plain FAIL and the duty line says why. The conservative
-direction matters here: `feature_absent` *removes* a device from the gate, so over-claiming absence
-would make promotion easier, not harder.
-
-`--postable` prints a CSV-ish block with **no raw SpO₂ values** — safe to paste on
-[#103](https://github.com/ryanbr/noop/issues/103). Promote `spo2_candidate_82` → `spo2Pct` only when
-**≥2 devices** each PASS (the tool's multi-device footer tracks that). This does **not** change app
-metrics by itself; it is the research harness for the split-evidence problem above.
-
## Mapping the layout — ground-truth correlation
An HCI capture on its own is a pile of un-labelled bytes. The fast way to label them is *known
@@ -307,20 +130,9 @@ lands in `parseFrameWhoop5` / `whoop_protocol.json`.
## How to help (5.0 / MG owners)
-1. Update to the latest NOOP, **Settings → Experimental → "Unlock WHOOP 5/MG deep data (R22)"**.
-2. With the strap **on and bonded**, tap **Send enable sequence to strap**.
-3. Keep wearing it, let it sync, then **share your strap log** on [#103](https://github.com/ryanbr/noop/issues/103) — we're looking for new deep
- records (type `0x2F`) to start arriving.
-4. Even better: a Bluetooth HCI capture of the **official app syncing a full night's history** shows the deep
- packets actually flowing and their layout. Method: iOS **PacketLogger** (Bluetooth diagnostic profile → `.pklg`)
- or Android **Developer Options → Bluetooth HCI snoop log** → `btsnoop_hci.log`, opened in Wireshark — the
- same iOS-HCI approach the [judes.club write-up](https://judes.club/writing/cracking-the-whoop-5-bluetooth-protocol/)
- used. Filter to just the WHOOP peripheral and attach it to [#103](https://github.com/ryanbr/noop/issues/103).
-5. **SpO₂ multi-device check:** after you have a history capture + your data export, run
- `python3 Tools/linux-capture/validate_spo2_candidate.py capture.json export/ --device