#103 probe: stop reporting silence, and one verb's refusal, as a verdict about the firmware - #914
Conversation
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
left a comment
There was a problem hiding this comment.
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. silenceWindowdegrades gracefully: a missing window falls back toserved no reply — unconfirmedrather than printing an empty duration.- Adding
undecodableto 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.
…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.
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.
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.
Follow-up to #103. The device-config read probe's no-answer verdict printed a single sentence —
— 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.sendcanreturnwithout transmitting at all — there is an early return whencmdCharacteristicis 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.
.undecodablefell 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:
unsupportedrefused by firmware (UNSUPPORTED)silentserved no reply in Ns — unconfirmedundecodablereplied but the frame did not decode — unconfirmeduntriednot askednoteTimeoutnow 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:
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 test— 428 tests, 0 failurescd android && ./gradlew testFullDebugUnitTest— 3199 tests, 0 failures, 5 skipped, confirmed by parsingandroid/app/build/test-results/testFullDebugUnitTest/*.xml(391 files) rather than trusting the exit codeFour tests carry the fix: the two-timeout case now pins the per-verb sentence and asserts
served by this firmwareandUNSUPPORTEDare 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.