Skip to content

Need one real WHOOP 4.0 GET_BATTERY_LEVEL capture: every in-tree fixture may be synthetic #900

Closed

Description

@ryanbr

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

  1. changed the title [-]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)[/+] on Jul 27, 2026
  2. ryanbr commented on Jul 27, 2026

    @ryanbr
    OwnerAuthor

    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:

    capture resp_seq result body
    GET_EXTENDED_BATTERY_INFO, real 4.0 (ExtendedBatteryProbeTests.realFrame, mV=3970) 13 SUCCESS(1) 29 bytes of real data
    GET_EXTENDED_BATTERY_INFO, real 4.0 (#791) 34 FAILURE(0) all zeros
    GET_DATA_RANGE, real 4.0 (#791) 10 SUCCESS(1) real data

    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 fixture

    Their 00 00 is padding someone typed, not something a strap emitted. Citing them as corroboration was my error.

    Of the remaining two, the Kotlin FramingTest fixture (91.2%) carries no provenance claim, and the parity-corpus frame shares a byte-identical header with the synthetic StreamsTests one — aa0f00c324141a, differing only in the value bytes — which is what deriving a test vector from another vector looks like. frames.json has no git history to check (the repo starts at one squashed commit).

    So the count of confirmed-real 4.0 GET_BATTERY_LEVEL captures may be zero, and the anomaly may be entirely an artifact of fixtures.

    What follows

    No decoder change. Special-casing GET_BATTERY_LEVEL to 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_LEVEL capture — a strap log or HCI trace from any 4.0 owner. If it shows 00 00 on a reply carrying a valid percentage, there is something genuinely command-specific here and it is worth handling. If it shows a sensible echo and 0x01, this closes and the fixtures should be relabelled.

    Separately, Whoop4ResponseResultTests.testSuccessfulBatteryReadStillReportsResultZero says 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.

  3. changed the title [-]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[/+] on Jul 27, 2026
  4. ryanbr commented on Jul 27, 2026

    @ryanbr
    OwnerAuthor

    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 FramingTest one "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 fixture
    • FramingTests — "Synthetic, CRC-valid frames built by scripts/gen_synthetic_fixtures.py (no real capture)"
    • Kotlin 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 StreamsTests synthetic. 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.

  5. ryanbr commented on Jul 28, 2026

    @ryanbr
    OwnerAuthor

    #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_LEVEL capture of known provenance. Nothing about what that result byte means is settled — #923 only stops the app claiming otherwise while we wait.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions