Ask the strap to LIST its device-config keys before guessing any: a read-only 115/116 enumeration probe, with a derived key-name sweep as the fallback (#103) - #898
Conversation
…ead-only 115/116 enumeration probe, with a derived key-name sweep as the fallback (#103)
Read-only follow-up to #890. On a WHOOP 5 MG (WS50_r03) both of that PR's read
verbs answered, and the reply turned out to be an existence oracle: a key name
the firmware has answers result=SUCCESS(1), one it does not answers FAILURE(0).
One round-trip is therefore a key-existence test, which makes a finite search
over key names possible for the first time.
But guessing is the fallback, not the plan. The CommandNumber table in this
repo's own whoop_protocol.json names a device-config ENUMERATION pair nothing
here has ever sent — 115 START_DEVICE_CONFIG_KEY_EXCHANGE and 116
SEND_NEXT_DEVICE_CONFIG — the structural twin of the 117/118 feature-flag pair
#872 built and a strap answered. If they answer, the strap hands over its own
device-config key list and no name needs guessing: the candidate sweep is
skipped and the report says so. A clean "115/116 are not served" is equally
useful, and is what promotes guessing from a shortcut to the only method.
115/116 are decoded by FeatureFlagProbe.parseStart/parseNext with the opcode
passed in, on the assumed-symmetric 117/118 record layout — an inference from
the naming symmetry in this repo's own table, not an observation. It fails
closed, and the walk inherits #874's discipline: the strap's own end marker
terminates it, but an entry whose name does not decode is counted and stepped
over rather than treated as the end.
The plan then reads the values of keys already known to exist, spends two
cross-namespace round-trips establishing whether 121 and 128 really serve
separate namespaces (if one serves both, everything afterwards goes through
it), and only then asks the guessed names.
ConfigKeySweep.catalogue is derived, not free-associated: every one of its 54
names is a cross-product of the morphology of the seventeen confirmed keys
(enable_<subsystem>_packets, make_<subsystem>_visible, enable_sig<N>
[_during_sleep], whoop_<metric>_in_<transport>, and six more) with the
vocabulary the firmware uses about itself — the CommandNumber table's optical
(107/108), labrador (124/125/139), research (131/132), afe (61/62) and its
revision tokens r7/r10/r11/r20/r21, plus the strap's own console-log subsystem
tags. One of those tags does most of the work: "SIGPROC: generated a valid SPO2
during sleep" pairs the firmware's SpO2 computation with the same "during
sleep" phrasing the confirmed key enable_sig11_during_sleep uses, which is why
the sig<N> number line is the catalogue's largest family. The eight
plain-English oxygen names a real MG already answered FAILURE move to
retiredKeys — out of the sweep, kept in the file so nobody proposes them again.
Read-only by construction: readOnlyOpcodes is {115, 116, 121, 128} and
isReadOnlyOpcode is the same predicate the 5/MG send() allowlist consults,
admitting them only while a probe is in flight. Tests assert 119, 120 and all
252 other opcodes are rejected. Per-step timeout retires a dead verb after one
window; the plan is capped at 128 round-trips; one run is bounded and reports
its own tested/untested arithmetic so a grown catalogue truncates visibly.
Gated behind Test Centre -> Connection like the #592/#690/#872/#890 probes. No
new storage, no migration, no new strings.
swift test: 436 passed / 0 failures. Android testFullDebugUnitTest: 3208 tests,
5 skipped, and 1 failure — com.noop.data.DeepCaptureMigrationTest, which fails
identically on a clean upstream/main and which nothing here touches.
…ong body shape as a clean negative (#103) Two verdict lines on this branch made positive claims from runs that had established nothing. Both are the same class of defect as the one just fixed in Whoop5EcgProbe (#896): a null read as a result. 1. "the strap enumerated its device-config namespace and returned no key NOOP did not already have" fired on `enumerationVerb == .answered` alone, and `noteEnumerationStart` treated ONLY result 3 as a refusal. So a strap that answered START_DEVICE_CONFIG_KEY_EXCHANGE(115) with FAILURE(0) and a zeroed record parsed fine (count = 0), the 116 follow-up decoded as exhausted, the walk ended with zero names — and the report asserted a successful, complete enumeration of a namespace nobody had listed. The same string also fired when the walk was TRUNCATED at maxEnumerationSteps (the cap went to stopReason only) and when every name had been enumerationSkipped, i.e. when OUR parser was the thing that failed. Now only SUCCESS(1) — or WHOOP 4.0, where the result byte's meaning has never been pinned here — opens a walk; 0 and 2 land on a new `inconclusive` status and 116 is never asked. The completeness claim additionally requires a walk that actually ran, reached the strap's own end marker, was not capped, and whose entries all decoded, with a distinct wording for each way that fails. The skipped>0 branch names our parser rather than the strap — the #874 discipline FeatureFlagProbe.verdict already carries, which this walk inherits along with its parser. 2. "this firmware has none of them (a clean negative)" fired on `unknown == asked` without checking that the run's calibration control had answered. ConfigKeySweep.existence maps FAILURE(0) to "no key by this name", but ValueResponse.isFailure documents the same code as "the verb exists, the request did not satisfy it (wrong body shape, or an unknown key)" — and the request body is inferred from the SET side, never observed. Since setStatus marks a verb `answered` for any non-UNSUPPORTED reply, a run in which the known-good control key `whoop_live_hr_in_adv_ind_pkt` (#181) itself returned FAILURE still reached "a clean negative", publishing our own body shape as the non-existence of a whole derivation family of names. The wording is now gated on `oracleCalibrated`: at least one .exists among the discovery/knownKey readings of the SAME run. Without it the verdict says the oracle is uncalibrated and the run is inconclusive. Ported to the Kotlin twin; the verdict strings stay byte-identical across platforms. The existing golden reports are unchanged — that scenario refuses 115 as UNSUPPORTED and its control answers SUCCESS, so neither gate moves it. Tests: ten new cases per side covering the FAILURE(0) and PENDING(2) replies to 115, the WHOOP 4.0 nil-result path, a walk that listed nothing, a walk whose names all failed our parser, a capped walk, a walk with no end marker, the uncalibrated-oracle sweep that was previously untested, and — as the guard against over-correcting — the completed walk and the calibrated sweep that must keep their original wording. Swift 446 tests / 0 failures. Android 3218 tests / 1 failure, the pre-existing DeepCaptureMigrationTest.repositoryInsertV18Aux_insertsThenPrunes (#897). Not touched here: the `answered == 0` "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" fallback, which is verbatim identical on main and is being fixed separately on fix/config-read-probe-verdict-honesty. Fixing it here too would collide.
|
Pushed 1. A refusal read as a completed enumeration
So a strap answering The same string also fired when the walk was truncated at Now:
2. A wrong body shape read as a clean negative
The control exists: the discovery read of The wording is now gated on
TestsTen new cases per side: the
Deliberately not fixed hereThe |
This branch has the pre-fix fallback verbatim — 4e47645 called it out and left it alone deliberately — so merging would put the over-claim back after it is fixed on main by fix/config-read-probe-verdict-honesty. Same change, adapted to the verbs and statuses this branch has. The no-answer verdict printed "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" for two timeouts (the send path can return without transmitting at all), for one refusal plus one timeout (the `||` spoke for a verb that was never heard from), and for an undecodable reply (which is affirmative evidence the strap DID transmit). Each verb now reports the evidence it produced: "refused by firmware (UNSUPPORTED)", "served no reply in Ns — unconfirmed", "replied but the frame did not decode — unconfirmed", "not asked". noteTimeout records the window so the verdict can name it. The strong "not served by this firmware" sentence is kept only when the firmware refused BOTH value verbs itself — the `||` is now an `&&` — and it names only the two verbs it speaks about. `inconclusive`, added by 4e47645, gets "replied but declined the request — unconfirmed". Like `undecodable` it is the strap ANSWERING, so it is evidence the verb exists and must not be worded like a verb never heard from. The enumeration verb is named in the per-verb sentence too, since on this branch 115/116 is the headline read verb. It spans two opcodes, so `outcome` takes the list of commands a verb can ride on rather than a single one. swift test 452 passed, 0 failures; gradlew testFullDebugUnitTest 3224 tests, 1 failure — DeepCaptureMigrationTest.repositoryInsertV18Aux_insertsThenPrunes, pre-existing on this branch's base and fixed by #897 on main — verified from the JUnit XML (390 files, one mtime). Refs #103.
Upstream #914 ("stop the config-read probe reporting silence as a firmware
verdict") rewrote the same four files this branch rewrites, and is the ONLY
upstream commit to touch any of them since the merge base — verified with
`git log <base>..upstream/main -- <the four files>`.
Resolved to this branch's version of all four, which is a strict superset rather
than a competing edit:
* All three tests #914 added — testAProbeThatAskedNothingClaimsNothing,
testAnUndecodableReplyIsNotReportedAsUnserved, and
testOneRefusalIsNotGeneralisedToTheVerbThatWasNeverHeardFrom — are already
present here on both platforms, adapted for the two extra verbs this branch
adds (115/116 enumeration), which is why their bodies differ.
* The eleven identifiers that exist upstream and not here are the same eleven
this branch renamed against the merge base, one-for-one on Swift and Kotlin —
e.g. allowlistAdmitsOnlyTheTwoReadVerbs -> ...OnlyTheFourReadVerbs,
bothVerbsUnsupportedEndsTheProbeAfterTwoRoundTrips ->
bothValueVerbsRefusedKeepsTheStrongSentence. Nothing upstream added was
dropped; the parity between the Swift and Kotlin renames is preserved.
* #914's rule is kept verbatim in substance: only UNSUPPORTED is the firmware's
own answer, a timeout is a fact about one run, and an undecodable reply is
affirmative evidence the strap transmitted. This branch states it more tightly
because it now has a measured result to state it against — both read verbs
ANSWERED on a real WS50_r03, and the reply is an existence oracle.
`outcome(_:for:)` takes an opcode ARRAY here (the enumeration verb rides two
opcodes, 115 and 116), which is why the verdict hunks conflicted rather than
merging. docs/PROTOCOL.md merged cleanly.
Verified: WhoopProtocol 473/473, android `./gradlew testFullDebugUnitTest`
3263 tests / 0 failures (5 skipped).
Read-only follow-up to #890. That probe asked a strap for one config key's VALUE, and on a WHOOP 5 MG (WS50_r03) both read verbs answered — the first time either has. This one acts on what the answer turned out to be.
1. The reply is an existence oracle
GET_FF_VALUE(128) andGET_DEVICE_CONFIG_VALUE(121) answer differently for a key name the firmware has and one it does not:result = SUCCESS(1)result = FAILURE(0)One round-trip is therefore a key-existence test: read-only, cheap, decisive. That turns "does a config key gate SpO2?" from an unanswerable question into a finite search over key names.
ConfigKeySweep.Existenceis that mapping, and it reads the result code and nothing else.UNSUPPORTED(3), any other code, and WHOOP 4.0's unlabelled result byte are allinconclusive— never folded into either answer.Values read on that strap, for the record: the sixteen R22 flags read back as written, and the Broadcast-HR device-config key
whoop_live_hr_in_adv_ind_pkt(the #181 key NOOP already writes, and #890's known-good control) read back'0'— the first time its value has been observed rather than only set.2. But guessing is the fallback, not the plan — 115/116 first
The
CommandNumbertable in this repo's ownwhoop_protocol.jsonnames two symmetric config namespaces, four verbs each:#872 built the feature-flag enumerate pair and a strap answered it, listing the sixteen keys
Whoop5Config.enableR22Sequencewrites. #890 built both value reads and the same strap answered those. The device-config enumerate pair — 115/116 — has never been sent by anything here, and it is the structural twin of the pair that already works.If 115/116 answer, the strap simply hands over its own device-config key list: no dictionary, no morphology, no sweep. So this probe asks first and guesses second, and when enumeration produces a list the candidate sweep is skipped entirely and the report says so.
A clean "115/116 are not served" is a useful, publishable result — it is exactly what promotes the guessing fallback from a shortcut to the only available method, and it costs one round-trip to establish.
115/116 are decoded by
FeatureFlagProbe.parseStart/parseNextwith the opcode passed in, i.e. on the assumed-symmetric 117/118 record layout. That is an inference from the naming symmetry in this repo's own table, not an observation, and it fails closed: a mismatch surfaces as a short record or an implausible count and retires the walk with a named reason rather than inventing key names. The walk inherits @ryanbr's #874 discipline — the strap's own end marker terminates it, but an entry whose NAME does not decode is counted and stepped over, so one bad entry cannot throw away every key after it.3. The rest of the plan
In order, each phase skipped when the verb that would carry it is dead:
A verb that is refused, silent or undecodable is retired, so a dead verb costs one 8 s window rather than one per key.
4. Why a derived candidate list beats free association
Eight plain-English oxygen names (
enable_spo2,enable_blood_oxygen,enable_pulse_ox, …) were asked of a real MG and all eight came back FAILURE. They now live inConfigKeySweep.retiredKeys— out of the sweep so nobody spends round-trips re-asking, in the file so nobody proposes them again. That all eight are product English, and all eight wrong, is the argument for deriving names from the firmware's own words.ConfigKeySweep.catalogueis that derivation. Every entry is a cross-product of two things already in this repo:A — morphology, the templates the seventeen confirmed key names follow:
B — vocabulary, the tokens the firmware uses about itself. Two in-repo sources, no others: the
CommandNumbertable (optical107/108,labrador124/125/139,research131/132,afe61/62,raw81/82, and the revision tokensr716,r10/r1163,r20/r21153/154), and the strap's own plaintext console log, whose subsystem tags this package already documents from real captures.One console line does most of the work here:
That is the firmware describing its own SpO2 computation, under the tag
SIGPROC, using the exactduring sleepphrasing the confirmed keyenable_sig11_during_sleepuses. IfsigisSIGPROC, thesig<N>number line is the likeliest home for an oxygen gate — which is why it is the catalogue's largest family, and why walking that number line requires guessing nothing at all: it just asks which N exist.54 candidate names, in eight families: sig number line (16), r22 version gaps (4), revision slot (8), optical/AFE (6), labrador/ECG (5), research + high-rate (4), SIGPROC + oxygen (7), device-config namespace (4).
The single entry I would watch first is
enable_r22_v7_packets. NOOP writes v2, v3, v4, v5, v6 and v8 — v7 is a hole in an otherwise contiguous observed series. A SUCCESS there proves the oracle finds keys NOOP does not already know, on the first run, and makes every other family worth extending.They are still guesses, labelled as such in the constant, in the report section header, and here.
5. A fully-negative sweep is a result
If all 54 come back
unknown, the verdict says so in those words — "asked 54 candidate key name(s); this firmware has none of them (a clean negative)" — and that rules out eight whole families of names, not just 54 strings. Combined with a negative on 115/116 it is worth publishing: it closes off the "a client-writable config key gates Blood Oxygen" branch of #103 much harder than any individual guess could.Contributions welcome, and cheap.
ConfigKeySweep.catalogueis the one place to edit; adding an entry adds one round-trip and nothing else. Say which template and which token it comes from, and add the same line to the Kotlin twin — a unit test asserts the two lists are identical, so a one-sided edit fails in CI rather than on someone's strap. If you have run the probe, the interesting thing to post is the report'sVerbs:block and theNamespace separation:verdict; those two shape what everyone else should sweep next.6. Read-only by construction, and testably so
DeviceConfigReadProbe.readOnlyOpcodesis{115, 116, 121, 128}, andisReadOnlyOpcodeis the same predicate the 5/MGsend()allowlist consults — admitting them only while a probe is actually in flight, so a default install can never form these bytes. There is no parallel copy to drift.allowlistAdmitsOnlyTheFourReadVerbs— the set is exactly those fourallowlistRejectsBothConfigWriteVerbs— 119 and 120 (andWhoop5Config.setConfigCmd/setDeviceConfigCmd) rejectedallowlistRejectsEveryOtherOpcode— all 252 remaining opcodes rejected, counted, including the destructive numbers (25 FORCE_TRIM, 29 REBOOT_STRAP, 32 POWER_CYCLE_STRAP)The SET verbs keep their own separate opt-in clauses, untouched. Per-step timeout is 8 s and terminates cleanly on no-reply; the plan is capped at 128 round-trips; one run is bounded at
maxKeysPerRunnames and reports its arithmetic ("N asked of M in the catalogue; K untested … run again to continue from entry X") so a catalogue grown past the budget truncates visibly instead of silently.Gated behind Test Centre → Connection like the #592/#690/#872/#890 probes, user-triggered from the Devices strap menu, result to a copyable dialog + the strap log. No new storage, no migration, no new strings.
7. Shape
Parsing, the plan and the report rendering live in the pure
WhoopProtocolpackage with Kotlin twins, so the whole probe is covered without a strap:Packages/WhoopProtocol/Sources/WhoopProtocol/ConfigKeySweep.swift(new — oracle, enumeration verbs, catalogue, batching)Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift(the plan + report)android/app/src/main/java/com/noop/protocol/ConfigKeySweep.ktandroid/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.ktFeatureFlagProbe.parseStart/parseNextgain anexpecting:parameter defaulting to 117/118 — additive, so #872's callers are unchanged.Golden assertions on both platforms: the full report text byte-for-byte, and the catalogue as one pinned string, so a wording drift or a one-sided list edit fails in tests rather than in a user's log.
Verification
swift test(WhoopProtocol) — 436 passed / 0 failures (417 before; +16 new inConfigKeySweepTests, andDeviceConfigReadProbeTestsrewritten around the new plan, 25 → 28)testFullDebugUnitTest— 3208 tests, 1 failure, 5 skipped (+19 new:ConfigKeySweepTest16,DeviceConfigReadProbeTest25 → 28). Counts read from the JUnit XML, not the exit code. Run locally; Android CI does not run on PRs.com.noop.data.DeepCaptureMigrationTest.repositoryInsertV18Aux_insertsThenPrunes(expected:<my-whoop> but was:<null>), which is pre-existing and unrelated: it fails identically on a cleanupstream/mainat 2b21ae0 with none of this branch applied, and nothing in this diff touchescom.noop.data, any migration, orWhoopStore.Strand(macOS) builds locally —** BUILD SUCCEEDED **(app-build.ymlis disabled)Tools/i18n_audit.py --ciandTools/doc_comment_lint.pyclean. No new strings, so no translation work.