Repository navigation
Need one real WHOOP 4.0 GET_BATTERY_LEVEL capture: every in-tree fixture may be synthetic #900
Description
Found while adding the Swift half of the result-code decode (#894 / #899). The behaviour is Android-only today and shipped in #791; #899 brings it to Apple for parity, so it is worth settling once rather than twice.
(Rewritten after surveying every fixture in the tree — the first version framed this as general uncertainty about the result byte. The evidence localises it much more tightly than that.)
The evidence
Every real capture in the repo, both families, decoded at [seq][result] (4.0 payload[0..1], 5/MG frame[11..12]):
| fixture | family | command | resp_seq | result |
|---|---|---|---|---|
| battery, 47% | 5/MG | GET_BATTERY_LEVEL |
2 | SUCCESS(1) |
| data-range | 5/MG | GET_DATA_RANGE |
7 | SUCCESS(1) |
| ECG toggle (#891) | 5/MG | TOGGLE_LABRADOR_FILTERED |
47 | SUCCESS(1) |
| data-range (#791) | 4.0 | GET_DATA_RANGE |
10 | SUCCESS(1) |
| empty ext-battery (#791) | 4.0 | GET_EXTENDED_BATTERY_INFO |
34 | FAILURE(0) |
| battery, 42.5% | 4.0 | GET_BATTERY_LEVEL |
0 | FAILURE(0) |
| battery, 91.2% | 4.0 | GET_BATTERY_LEVEL |
0 | FAILURE(0) |
The two 4.0 battery replies are the only rows that do not fit, and they both plainly succeeded — each carries a valid percentage the decoder reads from payload[2..4].
The part that narrows it
It is not only the result byte. resp_seq is 0 on those two rows as well, while every other real frame carries a plausible non-zero echo. The whole two-byte prefix is zeroed.
That points at a different explanation from the ones first listed here: a 4.0 GET_BATTERY_LEVEL reply may simply not carry a [seq][result] prefix at all. Its body would start with two bytes that are padding or something else entirely, with the value at a fixed offset — which is consistent with that decode having always read payload[2..4] without needing to interpret what precedes it.
If that is right, the #791 mapping is correct for the commands it was derived from and the fix is to withhold both fields for reply shapes that do not carry them, rather than to reinterpret 0x00.
What this is not
5/MG is consistent — every real 5/MG fixture reads SUCCESS(1) with a sensible echo. The only 5/MG FAILURE(0) in the tree is the GET_HELLO fixture, which is synthetic (hand-built with a fake name and zeros elsewhere, so the token never enters a committed fixture) and is not evidence about firmware. So this is a 4.0 question, and most likely a per-command one.
What would settle it
A 4.0 capture of GET_BATTERY_LEVEL alongside a command that genuinely fails, from one session. If battery keeps answering 00 00 while a real failure elsewhere answers a non-zero echo with 0x00, the prefix-absence reading is confirmed and the fix is per-reply-shape. A battery reply with a non-zero echo would refute it.
Until then the behaviour is pinned on both platforms — Whoop4ResponseResultTests.testSuccessfulBatteryReadStillReportsResultZero asserts it explicitly, so a correction has to come past a test rather than silently change a log.
Activity
Publish the command result code on Apple, as Android already does (#894)
[-]A successful 4.0 GET_BATTERY_LEVEL reply reads `result=FAILURE(0)`[/-][+]4.0 GET_BATTERY_LEVEL replies carry a zeroed [seq][result] prefix, so a successful read reads FAILURE(0)[/+]Publish the command result code on Apple, as Android already does (#894)
Publish the command result code on Apple, as Android already does (#894…
ryanbr commentedon Jul 27, 2026
Surveyed every WHOOP 4.0 COMMAND_RESPONSE frame in the tree before proposing a decoder change. The result argues against one, and against part of the evidence table above.
The mapping is confirmed, by exactly the pair this issue asked for
"What would settle it" wanted a real capture of a command that fails beside one that succeeds. Both already exist, on the same command:
GET_EXTENDED_BATTERY_INFO, real 4.0 (ExtendedBatteryProbeTests.realFrame, mV=3970)SUCCESS(1)GET_EXTENDED_BATTERY_INFO, real 4.0 (#791)FAILURE(0)GET_DATA_RANGE, real 4.0 (#791)SUCCESS(1)Same command, one success and one failure,
payload[1]discriminating them correctly, with a sensible echo in each. The #791 reading is right.Two of the four battery frames I cited are not evidence
They are labelled synthetic in their own source:
StreamsTests.swift:27—// COMMAND_RESPONSE GET_BATTERY_LEVEL(26), battery_pct=25.5 (synthetic)FramingTests.swift:27— a hand-built verify fixtureTheir
00 00is padding someone typed, not something a strap emitted. Citing them as corroboration was my error.Of the remaining two, the Kotlin
FramingTestfixture (91.2%) carries no provenance claim, and the parity-corpus frame shares a byte-identical header with the syntheticStreamsTestsone —aa0f00c324141a, differing only in the value bytes — which is what deriving a test vector from another vector looks like.frames.jsonhas no git history to check (the repo starts at one squashed commit).So the count of confirmed-real 4.0
GET_BATTERY_LEVELcaptures may be zero, and the anomaly may be entirely an artifact of fixtures.What follows
No decoder change. Special-casing
GET_BATTERY_LEVELto withhold the two fields would be reacting to hand-written padding, and it would contradict a mapping that a real success/failure pair now supports.Narrowing this issue to one concrete ask rather than closing it: a single real 4.0
GET_BATTERY_LEVELcapture — a strap log or HCI trace from any 4.0 owner. If it shows00 00on a reply carrying a valid percentage, there is something genuinely command-specific here and it is worth handling. If it shows a sensible echo and0x01, this closes and the fixtures should be relabelled.Separately,
Whoop4ResponseResultTests.testSuccessfulBatteryReadStillReportsResultZerosays the two frames "both have that shape, so it is not a one-off". That leans on the unprovenanced Kotlin fixture and should be softened to match the above.[-]4.0 GET_BATTERY_LEVEL replies carry a zeroed [seq][result] prefix, so a successful read reads FAILURE(0)[/-][+]Need one real WHOOP 4.0 GET_BATTERY_LEVEL capture: every in-tree fixture may be synthetic[/+]Check a fixture's provenance before citing it as evidence (#900)
ryanbr commentedon Jul 27, 2026
Correction to my previous comment, which was itself partly wrong.
I wrote that two of the four battery fixtures are synthetic and that the Kotlin
FramingTestone "carries no provenance claim". Wrong on the second count. Three of the four declare themselves generated, in their own file headers rather than beside the frame:StreamsTests— "Synthetic, protocol-valid frames … No real biometric capture is embedded", plus(synthetic)again at the fixtureFramingTests— "Synthetic, CRC-valid frames built byscripts/gen_synthetic_fixtures.py(no real capture)"FramingTest— "All vectors were generated independently (Python: zlib CRC-32, table CRC-8, CRC16-Modbus)"I had been reading the ten lines around each fixture and not the top of the file. Twice.
That leaves the parity-corpus frame as the only candidate real capture, and it shares a byte-identical envelope with the
StreamsTestssynthetic. So the count of confirmed-real captures behind the apparent counterexample is between zero and one — the ask in the title stands, and if anything is firmer than when I retitled it.#903 records this in the tree.
Check a fixture's provenance before citing it as evidence (#900) (#903)
Stop a working 4.0 battery read logging as a failure (#900)
Stop a working 4.0 battery read logging as a failure (#900) (#923)
ryanbr commentedon Jul 28, 2026
#923 landed a piece of this that did not need the capture: the strap log no longer prints a bare
GET_BATTERY_LEVEL(26) → FAILURE(0)for a battery read that worked.That line fired on every 4.0 battery read, next to a gauge showing the correct percentage — the exact misreading this issue was opened about, manufactured in-app on a log people paste into issues. It now annotates when the same frame carried a value, and says the result byte is unestablished rather than asserting a failure. A genuine valueless failure is untouched.
This issue stays open and the ask is unchanged: one real WHOOP 4.0
GET_BATTERY_LEVELcapture of known provenance. Nothing about what that result byte means is settled — #923 only stops the app claiming otherwise while we wait.Publish the command result code on Apple, as Android already does (ry…
Check a fixture's provenance before citing it as evidence (ryanbr#900) (
Stop a working 4.0 battery read logging as a failure (ryanbr#900) (ry…
diag: capture the 4.0 GET_BATTERY_LEVEL frame in the strap log (#900) (…
chore(staging): re-cut the 10.1.1 testing build (versionCode 339, iOS…
Keep the WHOOP 4.0 device key out of the strap log
Keep the WHOOP 4.0 device key out of the strap log (#1610)
diag(clock): quote the frame, claim nothing about it
ble: redact a hex-hidden serial under ANY label, not just payload=
ble: redact a hex-hidden serial on Apple too (#1834)