Skip to content

fix: do not report a failed WHOOP 5 flag read as a value - #2193

Merged
ryanbr merged 2 commits into
ryanbr:mainfrom
Trillient:pr/whoop5-flag-key-enumeration
Sep 15, 2026
Merged

ryanbr merged 2 commits into
ryanbr:mainfrom
Trillient:pr/whoop5-flag-key-enumeration

Conversation

@Trillient

Copy link
Copy Markdown

What this PR does

DeviceConfigReadProbe built its report from r.value(for:) regardless of the reply's result code. A FAILURE reply echoes the requested key back with zero padding, so a rejected read was rendered as a stored value of 0 — indistinguishable in the report from a key that genuinely holds 0.

Take a value only when the reply reported success (or carried no result code), and say plainly when read verbs answered but no reply succeeded, instead of presenting a fabricated zero.

Secondly, read four additional WHOOP 5 flag names returned by a complete 117/118 enumeration on firmware 50.41.1.0: enable_r22_v9_packets, enable_frizzle_burst_mode, ir_1x_enable, enable_rocky2. Their effects and accepted values are unknown, so these are read-only additions — the R22 write sequence is untouched, and the report title distinguishes enumerated names from names NOOP itself writes.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

  • Hardware: WHOOP 5.0, firmware 50.41.1.0. The full probe run received 29 replies — 21 successful and eight rejected guessed keys — with no timeout and no reconnect. All four enumerated keys answered CRC-valid SUCCESS carrying ASCII '2'. The eight rejected keys are what exposed the bug: each came back FAILURE with the key echoed and zero padding, which the old code reported as a value of 0.
  • swift test in Packages/WhoopProtocol: 707 tests, 1 skipped, 0 failures.
  • Negative control is weaker here and I'd rather say so than overstate it. Reverting only DeviceConfigReadProbe.swift to main makes DeviceConfigReadProbeTests fail to compile (the new tests reference knownFlagKeys), rather than fail assertions. So it demonstrates the tests exercise the new surface, not that they'd catch a silent regression of the guard alone. If you want a guard-only regression test that survives without the enumeration change, I'll add one.

Checklist

  • Swift package tests pass for any package I touched (swift test in Packages/WhoopProtocol)
  • Android unit tests pass if I touched android/not touched
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens — no UI changes
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

One design question for you

Both parts are currently behind #if os(macOS), because that's where the hardware evidence was taken.

That's clearly right for the four enumerated key names — they're observations from one firmware on one platform. I'm less sure it's right for the FAILURE guard: reporting a rejected read as a value of 0 looks like a plain correctness bug rather than a platform-specific one, and the argument for fixing it everywhere seems stronger than the argument for scoping it. I left it scoped to keep the change conservative and match where the evidence came from, but I'd rather you decide — I'm happy to lift the guard out of the #if in this PR.

Related issues

None.

`DeviceConfigReadProbe` built its report from `r.value(for:)` regardless of the
reply's result code. A FAILURE reply echoes the requested key back with zero
padding, so a rejected read was rendered as a stored value of 0 — indistinguishable
from a key that genuinely holds 0.

Take a value only when the reply reported success (or carried no result code), and
say plainly in the report when verbs answered but no reply succeeded, rather than
presenting a fabricated zero.

Also read four additional WHOOP 5 flag names returned by a complete 117/118
enumeration on firmware 50.41.1.0: enable_r22_v9_packets, enable_frizzle_burst_mode,
ir_1x_enable and enable_rocky2. All four answered CRC-valid SUCCESS with ASCII '2'
across 29 replies (21 successful, eight rejected guessed keys), with no timeout or
reconnect. Their effects and accepted values are unknown, so they are read-only
additions: the R22 write sequence is untouched, and the report title distinguishes
enumerated names from names NOOP itself writes.

Scoped to macOS, where the hardware evidence was taken; iOS and Android keep their
existing probe plans and decoding.
@ryanbr

ryanbr commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Reviewed at 6cf7fe4. The bug is real and the fix is right: a FAILURE reply echoes the key back with zero padding, so reporting r.value(for:) regardless of the result code turned every rejected read into a stored 0, which is indistinguishable from a key that genuinely holds 0. Eight rejected keys in your own run is exactly how that surfaces.

Taking you up on the offer. You wrote that the negative control is weaker here, that reverting DeviceConfigReadProbe.swift makes the tests fail to compile rather than fail assertions, and that it therefore shows the tests exercise the new surface rather than that they would catch a regression of the guard. That is an accurate description of what you have, and I would rather have the guard-only test than not.

The guard is the fix; the four enumerated names are additive and would survive a silent revert of it. A test that constructs a FAILURE reply with the key echoed and zero padding, and asserts the report does not present a value, is the one that fails if someone later simplifies the result-code check away. Please add it.

The read-only framing on the four new names is the right call as well, given their effects and accepted values are unknown, and keeping the R22 write sequence untouched is what makes that safe. Distinguishing enumerated names from names NOOP itself writes, in the report title, is a detail I would not have thought to ask for and would have missed.

15 of 15 checks. Happy to merge once the guard test is in.

@bhelm

bhelm commented Sep 14, 2026

Copy link
Copy Markdown

Some additional context from our WHOOP 5 protocol work:

  • enable_r22_v9_packets participates in R22 packet-version selection.
  • enable_frizzle_burst_mode is associated with burst/cadence processing, although its exact timing remains unresolved.
  • ir_1x_enable is associated with the optical processing path; its precise physical effect remains unresolved.
  • enable_rocky2 does not appear in our 50.42.1.0 inventory. That version contains enable_rocky_again instead. They should remain separate, version-bound names unless equivalence is independently established.

The value 2 observed in this PR should therefore be reported as the stored value, not described as “enabled” or assigned a universal meaning across firmware versions.

Related documentation:

Add a guard-only regression test for the result-code check in
DeviceConfigReadProbeReport.noteReply. A CRC-valid WHOOP 5 GET_FF_VALUE(128)
FAILURE reply echoing the key with a zero byte must not produce a reading
value or a rendered value=, while a SUCCESS reply with the identical record
still reports a genuine 0.

The test uses only API present on main, so removing the check fails an
assertion rather than the build.
@Trillient

Copy link
Copy Markdown
Author

@ryanbr Added testReportDoesNotPresentAFailedReadAsAStoredValue.

It builds a CRC-valid WHOOP 5 GET_FF_VALUE(128) reply with result FAILURE, the key echoed in the 32-byte field and a zero byte after it. First it asserts that the shared decoder would read that as 0, so the fixture really is the dangerous shape. Then it passes the reply through the report and asserts that the reading carries no value, that the FAILURE result code is kept, and that the rendered report contains no value=. A SUCCESS reply with the same record still reports a real 0, so the test can't pass just because the report drops every value. It uses only API that already exists on main.

Negative controls, run rather than argued:

  • Removing only the result-code check from noteReply: it compiles, and the new test fails on its two FAILURE assertions (so does the existing echoed-failure test).
  • Running it against main's DeviceConfigReadProbe.swift (with only the one test that calls knownFlagKeys(for:) removed): the same two assertions fail, and the SUCCESS half passes.

swift test in Packages/WhoopProtocol: 708 tests, 1 skipped, 0 failures. The guard and the new test both stay inside #if os(macOS). The offer to lift the guard out still stands if you want it.

@bhelm Thanks, agreed. The report doesn't interpret the value: it prints the raw stored byte, value='2' (0x32), and nothing in the change treats it as "enabled". The four names are documented as observed on 50.41.1.0 only. I've left enable_rocky2 as it is and haven't added enable_rocky_again, because I haven't seen that one on a wire myself. On firmware that doesn't have a name, I'd expect the read to be rejected, as the guessed keys were in this run. That's the case this PR stops reporting as a 0.

@ryanbr

ryanbr commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Reviewed at 75668ee. The test is exactly what I asked for and better built than I expected. It also cannot do its job yet, for a reason that answers your design question outright.

The test is right

The two things that make it a guard test rather than a shape test are both there. It asserts the fixture is dangerous before using it, reply.value(for:) == 0, so the frame really is the one that used to be misreported. And the SUCCESS case with the identical record still reports a real 0, so it cannot pass by the report having simply stopped rendering values. Using only API that exists on main is the part that makes it survive a revert of the enumeration change, which was the whole point.

Your negative control is the right one too, and run rather than argued.

It is inside the #if os(macOS) block

The gate opens at line 162 and closes at 224, with the new test at 197 inside it. So on any platform that is not macOS it does not compile in. Run here on Linux:

swift test --filter testReportDoesNotPresentAFailedReadAsAStoredValue
Executed 0 tests, with 0 failures

That matters because of what sits beside it in the source:

#if os(macOS)
let value = r.resultCode == nil || r.resultCode == 1 ? r.value(for: step.key) : nil
#else
let value = r.value(for: step.key)
#endif

DeviceConfigReadProbe is not a macOS file. It has no wholesale platform gate, so it builds for iOS too, and on iOS the #else branch still reports a rejected read as a stored 0. The bug you found is live there, and the test that would catch it is gated off by the same condition that gates the fix.

So the guard-only test is guard-only on one platform, and the platform it runs on is the one that already has the guard.

Your design question, answered by that

You asked whether the FAILURE guard should be lifted out of the #if, said the argument for fixing it everywhere seemed stronger, and left it to me.

Lift it. Your instinct was right and the evidence above is why: this is not a platform observation, it is a decoder-adjacent correctness bug in a report, and it is currently unfixed and untested on iOS.

The four enumerated key names stay scoped. Those genuinely are observations from one firmware on one platform, and you drew that distinction yourself.

The test has to come out of the #if with the guard. A guard test that only compiles where the guard already applies is the thing we both just watched happen.

@bhelm's note is worth acting on

enable_rocky2 is absent from their 50.42.1.0 inventory, which carries enable_rocky_again instead. Keeping them as separate version-bound names rather than assuming equivalence is right, and it is an argument for the enumerated names staying exactly as scoped as they are.

Their point about reporting the stored value rather than calling 2 "enabled" is already satisfied: the report renders value=0x00 style raw bytes, which your own test asserts. Worth confirming to them rather than leaving it open.

Lift the guard and its test, and I will take this.

@ryanbr
ryanbr merged commit 4619a94 into ryanbr:main Sep 15, 2026
15 checks passed
ryanbr added a commit that referenced this pull request Sep 15, 2026
…too (#2223)

Follow-up to #2193 by @Trillient, which stopped the report claiming a value from
a FAILURE reply. Two things were left, and the second was not visible from that
PR at all.

@Trillient asked whether the guard belonged inside `#if os(macOS)`, judged that
it did not, and left the call here. They were right, and the evidence is sharper
than the argument. DeviceConfigReadProbe has no platform gate and builds for
iOS, so the `#else` branch still rendered a rejected read as a stored 0 there.
The guard-only test sat inside the same `#if`, so it could not compile on the
platform where the bug survived: `Executed 0 tests` off macOS. A guard test that
only runs where the guard already applies cannot catch what it was written for.
Both are out of the `#if`, which brings three formerly-gated tests into the
suite everywhere, 707 to 710.

The verdict came with it. The non-macOS branch said only "no reply echoed its
key", which is the wrong sentence for a strap that answered and refused every
read.

Then the part found by grepping for the string being deleted: Android has a
DeviceConfigReadProbe twin, #2193 correctly declared it did not touch android/,
and nobody had asked whether the twin shared the fault. It did.

    val value = r.valueFor(step.key)

No result-code check, exactly what Swift had before, live on every Android
device and entirely outside the Swift fix. Guarded now, with the same two
sentences and a Kotlin twin of the guard test.

Two further gaps came out of re-reviewing this rather than the code. Changing
the Kotlin verdict passed the suite, because Kotlin asserted neither sentence
while Swift asserted both, which is how they drifted in the first place. Pinning
the rejection branch then left the other branch unpinned, found by listing both
suites and comparing them rather than by reading again.

Every Swift probe test now has a Kotlin counterpart except
ObservedWhoop5FlagsAreReadWithoutExtendingTheWriteSequence, covering the four
enumerated key names. Those stay macOS-scoped: observations from one firmware on
one platform, which is @Trillient's own distinction, and @bhelm notes
enable_rocky2 is absent from their 50.42.1.0 inventory, which carries
enable_rocky_again instead.

Negative controls run on both sides. Removing the Swift guard fails three
assertions ON LINUX, impossible before this change. Removing the Kotlin guard
fails aFailedReadIsNotReportedAsAStoredValue. Reverting the Kotlin verdict fails
theVerdictSaysWhenEveryReplyWasRejectedRatherThanUnverified, which before this
failed nothing. Every file restored byte-for-byte, verified by grep.

WhoopProtocol 710 tests 1 skipped 0 failures. com.noop.protocol 576 tests 0
failures. Parity ledger reports no new findings, and com/noop/protocol is not in
the excluded globs, so it genuinely scanned these files.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants