From 102697aa5549900a3fd32240f604e410e637bcf0 Mon Sep 17 00:00:00 2001 From: ryanbr Date: Mon, 27 Jul 2026 19:15:54 -0700 Subject: [PATCH] docs(protocol): document the COMMAND_RESPONSE body (#894, #891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROTOCOL.md documented the envelope down to `payload [11..]` and stopped, so the two bytes every reply actually opens with were described nowhere — even though #899 made both decoders publish them and added CommandResult to the schema. Three places had been describing the same layout ad hoc instead: DeviceConfigReadProbe and FeatureFlagProbe each say "the 2-byte response header (pay[1] is the 5/MG result code)" in their own comments, and the ECG paragraph mentions a result code in passing. New §2.4 states it once, for both families: resp_cmd/resp_seq/result at [6][7][8] on 4.0 and the same +4 on 5/MG. It also records that resp_seq is the strap's counter rather than the echoed host-assigned envelope seq — a real capture shows envelope seq 147 beside resp_seq 2, and the repeated-resp_seq signature is what identified a duplicated write in #791. The part worth writing down is the first BODY byte. GET_BATTERY_LEVEL puts the charge percentage there (47 in the hardware-confirmed fixture), so the slot carries real data, but on every other captured command it has only ever been 1. For the wrist and ECG toggles what that means is open: sending SELECT_WRIST with argument 0 got 1 back, which refutes an echo of the request, but every frame captured so far had a stored value of 1, so "reads back stored state" and "this handler writes a literal 1" predict identically on all of them. Documented as open, with what would settle it, rather than resolved — the decoder deliberately leaves the byte unnamed and this explains why to the next reader. Old §2.4 Checksums and §2.5 Reassembly shift to §2.5/§2.6. No cross-reference in the tree points at either; the only internal PROTOCOL.md section reference is §2.2, unchanged. Every claim checked against source before writing: both platforms' offsets from the decoders, the PENDING-then-SUCCESS and UNSUPPORTED(3)-on-haptics results from the captures that produced them, and the body-byte table recomputed from the committed fixtures. --- docs/PROTOCOL.md | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 34c6bac602..85be882044 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -188,7 +188,46 @@ public func parseFrame(_ frame: [UInt8], family: DeviceFamily) -> ParsedFrame `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 Checksums +### 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 | |-----------|----------|------------| @@ -202,7 +241,7 @@ and `classifyHistoricalMeta(_:)` refuses to act on a frame where `p.crcOK == fal 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.5 Reassembly +### 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