Skip to content

#103 probe: stop reporting silence, and one verb's refusal, as a verdict about the firmware - #914

Merged
ryanbr merged 2 commits into
ryanbr:mainfrom
vishk23:fix/config-read-probe-verdict-honesty
Jul 28, 2026
Merged

#103 probe: stop reporting silence, and one verb's refusal, as a verdict about the firmware#914
ryanbr merged 2 commits into
ryanbr:mainfrom
vishk23:fix/config-read-probe-verdict-honesty

Conversation

@vishk23

@vishk23 vishk23 commented Jul 28, 2026

Copy link
Copy Markdown

Follow-up to #103. The device-config read probe's no-answer verdict printed a single sentence —

neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware

— for three runs that establish three different things, and for one that establishes the opposite. That sentence is the probe's headline: it is what gets pasted into an issue when someone runs the probe and nothing comes back, so it is the one string that most needs to be true.

The three over-claims

1. Two timeouts asserted a firmware behaviour. BLEManager.send can return without transmitting at all — there is an early return when cmdCharacteristic is missing, and another in the 5/MG allowlist else-branch. A run in which nothing ever reached the strap still rendered a claim about what the strap's firmware serves. The file's own header names answered / rejected as UNSUPPORTED / silent as three distinct outcomes, and then the verdict collapsed silence into the same factual claim as a refusal.

2. The || generalised one refusal to both verbs. 128 answering UNSUPPORTED(3) while 121 times out printed "neither … is served by this firmware — rejected as UNSUPPORTED". 121 was never refused; it was never heard from.

3. .undecodable fell through to the bare "is not served" sentence. A CRC or envelope failure is affirmative evidence the strap did transmit — the opposite of what that sentence says.

The fix

The sentence is now per-verb and tied to the evidence that produced it:

status wording
unsupported refused by firmware (UNSUPPORTED)
silent served no reply in Ns — unconfirmed
undecodable replied but the frame did not decode — unconfirmed
untried not asked

noteTimeout now records the reply window rather than only tracing it, so the verdict can name how long nothing came back for instead of converting that silence into a claim about firmware.

Only UNSUPPORTED is the firmware answering, so the strong "not served by this firmware" wording survives in exactly one case — the firmware refused both verbs itself, where the || is now an &&. That run keeps its original string verbatim.

Before / after, for the one-refusal-plus-one-timeout run:

- neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware — rejected as UNSUPPORTED
+ no read verb answered — GET_FF_VALUE(128) refused by firmware (UNSUPPORTED); GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed

Scope

Verdict wording only. No change to the wire, the plan, the read-only allowlist, the parse path, or any status transition — setStatus's "a verdict already reached is not upgraded away" rule is untouched. The cross-platform golden report test is unaffected because it exercises the answered path.

Mirrored byte-for-byte in com.noop.protocol.DeviceConfigReadProbe, per the parity contract; the Kotlin twin tests assert the same literals.

Same defect class as the ECG probe fix on #896 — a verdict asserting a conclusion the run's own inputs could not support. Different file, same failure mode: reading the absence of a reply as evidence for the hypothesis the code was written to print.

Verification

  • cd Packages/WhoopProtocol && swift test428 tests, 0 failures
  • cd android && ./gradlew testFullDebugUnitTest3199 tests, 0 failures, 5 skipped, confirmed by parsing android/app/build/test-results/testFullDebugUnitTest/*.xml (391 files) rather than trusting the exit code

Four tests carry the fix: the two-timeout case now pins the per-verb sentence and asserts served by this firmware and UNSUPPORTED are both absent; new tests cover one-refusal-plus-one-timeout, two undecodable replies, and a probe that asked nothing. Kotlin twins for each. No hardware run — this is pure string logic over synthetic report state, and every branch is reachable from unit tests.

The #103 probe's no-answer verdict printed one sentence — "neither
GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware"
— for three runs that establish three different things, and for one that
establishes the opposite.

Two timeouts got it. But `BLEManager.send` can return without transmitting at
all: there is an early return when `cmdCharacteristic` is missing, and another
in the 5/MG allowlist else-branch. So a run in which nothing ever reached the
strap rendered a claim about what the strap's firmware serves. The file's own
header names answered / rejected as UNSUPPORTED / silent as three distinct
outcomes and then the verdict collapsed silence into the same factual claim as
a refusal.

One refusal got it for both verbs. The `||` meant 128 answering UNSUPPORTED(3)
while 121 timed out printed "neither ... is served — rejected as UNSUPPORTED".
121 was never refused; it was never heard from.

An undecodable reply got the bare "is not served" sentence. A CRC or envelope
failure is affirmative evidence the strap DID transmit, which is the opposite
of what that sentence says.

The sentence is now per-verb and tied to the evidence that produced it:
UNSUPPORTED is "refused by firmware (UNSUPPORTED)", a timeout is "served no
reply in Ns — unconfirmed" (the window is now recorded, not just traced),
an undecodable reply is "replied but the frame did not decode — unconfirmed",
and an untried verb is "not asked". Only UNSUPPORTED is the firmware
answering, so the strong "not served by this firmware" wording survives in
exactly one case: the firmware refused BOTH verbs itself, where the `||` is
now an `&&`.

Same defect class as the ECG probe fix on #896: a verdict asserting a
conclusion the run's own inputs could not support. Mirrored byte-for-byte in
com.noop.protocol.DeviceConfigReadProbe; the golden cross-platform report test
is unaffected because it exercises the answered path.

swift test 428 passed, 0 failures; gradlew testFullDebugUnitTest 3199 tests,
0 failures, 5 skipped, verified from the JUnit XML (the
DeepCaptureMigrationTest failure noted in #897 is fixed on current main and
passes here).

Refs #103.

@ryanbr ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the premise rather than taking it, and it holds on both counts.

BLEManager.send really can return without transmitting — guard state.connected, let p = peripheral, p.state == .connected, let ch = cmdCharacteristic else at :1471, plus the 5/MG allowlist else-branch. So a timeout genuinely is not evidence a frame reached the strap, and a verdict built on it was speaking for firmware that may never have been asked.

And the file's own header at :26 already named answered / rejected as UNSUPPORTED / silent as three outcomes, while the verdict collapsed two of them into one sentence. The model was right and the string did not follow it — which is the most persuasive form this argument can take, because it is the file disagreeing with itself rather than a new opinion.

The ||&& is the heart of it and it is correct: "not served by this firmware" is a claim about firmware, so it survives only where the firmware itself refused both verbs. Everything else reports per verb against the evidence that verb produced.

Verified

  • Parity is real, not asserted. I diffed the added user-visible literals between the Swift and Kotlin halves — not asked, refused by firmware (UNSUPPORTED), replied but the frame did not decode — unconfirmed, served no reply, no read verb answered, and the both-refused sentence — and they match with zero mismatches. Worth doing because a wording change mirrored by hand is exactly where the two platforms drift apart silently.
  • silenceWindow degrades gracefully: a missing window falls back to served no reply — unconfirmed rather than printing an empty duration.
  • Adding undecodable to the header's list is the right call, and the reasoning — a frame that failed to decode is affirmative evidence the strap transmitted, the opposite of "not served" — is the sharpest line in the change.
  • Verification method noted: parsing the 391 JUnit XML files rather than trusting the exit code is the right instinct, and it is the same reason this repo's Android suite went unchecked for weeks.

The failing check is mine, not yours

check is red on the i18n gate for AppChangelog.kt:42, a hardcoded What's New title — a line this PR does not touch. Cutting 9.2.1 added it: appchangelog-gen.py writes the Android title as a raw Kotlin literal, which is #878, closed but never actually fixed. Fixed on main in #916 and #878 is reopened for the generator.

Two things made it land on your PR rather than being caught at the source: the release workflow pushes with GITHUB_TOKEN, so GitHub ran no workflow at all on the release commits, and the audit is a whole-tree gate rather than a diff, so it fails every open PR on a line none of them touched. Merge main and it clears — nothing to change here.

One observation, not a request

undecodable and silent both end in unconfirmed, while the header now argues they are opposites in what they establish about transmission. The sentences do carry the distinction, so a reader gets it; it is only the one-word tag that flattens it. Fine as is — noting it in case a later reader tries to count "unconfirmed" verbs and finds two different things under one label.

Good change. Green once it picks up main.

@ryanbr
ryanbr merged commit 2b12cef into ryanbr:main Jul 28, 2026
11 checks passed
ryanbr added a commit that referenced this pull request Jul 28, 2026
…rdict (#690) (#918)

The bare-stub report said two things at once:

    Verdict: opcode 84 answered with a bare stub — ambiguous
    ...
    No payload beyond the command byte (bare stub) — no body-location data on this firmware

The verdict is right and the detail line settles what the verdict just called
unsettled. A reply carrying no payload is one reply: it does not distinguish a
firmware without body-location data from one that needs the strap worn, or in a
determinate position, or asked with an argument this probe does not send. This
file's own enum has UNKNOWN(0) and NOT_CONCLUSIVE(128), so the firmware plainly
models "cannot tell right now" — which is exactly the reading the detail line
ruled out.

Same class as #913 and #914: a probe asserting a conclusion the run's inputs
could not support, in the sentence a reader quotes. Found while sweeping the
probes after #913 rather than reported.

Both platforms carried the identical string — this is a shared defect, not a
divergence — so both are reworded to say what was observed and what it does not
establish, and both tests now assert the over-claim is ABSENT rather than only
that "bare stub" appears. The old assertions passed under either wording, which
is why the contradiction survived.

The golden parity lock is unaffected: it pins a payload-bearing frame, so the
bare-stub branch was never covered by it.
ryanbr added a commit that referenced this pull request Jul 28, 2026
WhoopBleClient logs every non-SUCCESS command response, which is how the MG
haptics rejection (#48) would surface in-app. But every WHOOP 4.0
GET_BATTERY_LEVEL reply on record carries a zeroed [seq][result] prefix, so a
battery read that returned a perfectly good percentage logs as:

    Command response: GET_BATTERY_LEVEL(26) → FAILURE(0)

next to a battery gauge showing 42%. A log line asserting a failure that did not
happen is exactly the artefact #900 exists to warn about — that issue began when
a FAILURE(0) was read as evidence about firmware, and this line manufactures the
same misreading on every battery read a 4.0 owner makes.

The line still prints. Suppressing it would hide the anomaly, and the anomaly is
the thing #900 wants a capture of. It is annotated instead, when the same frame
also decoded a value:

    Command response: GET_BATTERY_LEVEL(26) → FAILURE(0) (the reply still
    carried a value: battery 42.5% — see #900, the 4.0 result byte is not
    established)

A genuine failure that carried no value — the extended-battery case from #791,
the UNSUPPORTED rejections — is untouched, because the annotation is gated on a
decoded value being present.

This does not decide what the 4.0 result byte means; #900 still wants one real
capture of known provenance. It stops the log asserting an answer in the
meantime, the same discipline as #913, #914 and #918.

Android-only: the Swift side has no command-response result consumer, so there
is no parity twin to change. No test — the string is inside a class that needs
the Android framework, and the annotation is a log line rather than behaviour;
the rendered output was checked against both real 4.0 battery fixtures instead.
ryanbr added a commit that referenced this pull request Jul 28, 2026
WhoopBleClient logs every non-SUCCESS command response, which is how the MG
haptics rejection (#48) would surface in-app. But every WHOOP 4.0
GET_BATTERY_LEVEL reply on record carries a zeroed [seq][result] prefix, so a
battery read that returned a perfectly good percentage logs as:

    Command response: GET_BATTERY_LEVEL(26) → FAILURE(0)

next to a battery gauge showing 42%. A log line asserting a failure that did not
happen is exactly the artefact #900 exists to warn about — that issue began when
a FAILURE(0) was read as evidence about firmware, and this line manufactures the
same misreading on every battery read a 4.0 owner makes.

The line still prints. Suppressing it would hide the anomaly, and the anomaly is
the thing #900 wants a capture of. It is annotated instead, when the same frame
also decoded a value:

    Command response: GET_BATTERY_LEVEL(26) → FAILURE(0) (the reply still
    carried a value: battery 42.5% — see #900, the 4.0 result byte is not
    established)

A genuine failure that carried no value — the extended-battery case from #791,
the UNSUPPORTED rejections — is untouched, because the annotation is gated on a
decoded value being present.

This does not decide what the 4.0 result byte means; #900 still wants one real
capture of known provenance. It stops the log asserting an answer in the
meantime, the same discipline as #913, #914 and #918.

Android-only: the Swift side has no command-response result consumer, so there
is no parity twin to change. No test — the string is inside a class that needs
the Android framework, and the annotation is a log line rather than behaviour;
the rendered output was checked against both real 4.0 battery fixtures instead.
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.

2 participants