Make the ECG gate hypothesis testable: an opt-in enable_raw_data_w_ecg write with a mandatory read-back (#891, #103) - #907
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.
…g write with a mandatory read-back (#891, #103) commands answer SUCCESS and produce zero ECG packets in a 30-second listen. This adds the one experiment that can move it — and, deliberately, nothing else. The key was not guessed. #898's read-only 115/116 enumeration asked the strap to list its own device-config keys and it answered revision=1 count=7, walking to a clean terminator: sigproc_wear_detect enable_rfid max_collection_backlog cont_collection_mode whoop_live_hr_in_adv_ind_pkt whoop_live_2_hrm_devices enable_raw_data_w_ecg Six of those seven were unknown here. On that strap enable_raw_data_w_ecg reads '0'. A key pairing "raw data" with "ecg", sitting at '0' on hardware whose ECG commands accept and emit nothing, is the leading candidate for the gate. WHETHER FLIPPING IT PRODUCES ECG DATA IS UNKNOWN. This is not what the official client writes, and no claim is made that it replicates any other client's behaviour. A confirmed '1' with still no packets removes the leading hypothesis from #891 and is exactly as publishable as a positive. Config writes on this firmware are demonstrated rather than assumed: running the existing enable_r22_* sequence (#174) moved enable_sig12 from '2' to '1', confirmed by a 121 read either side. The read-back is the proof, never the ack. The write's own COMMAND_RESPONSE is recorded and not believed — a SUCCESS result byte is not evidence that state changed. Every write is followed by GET_DEVICE_CONFIG_VALUE(121) on the same key and only the value that comes back is reported; a SUCCESS ack whose value did not move is reported as "unchanged", not as success. (Read-back framing owed to @ryanbr on #891.) The allowlist is now key-aware, which is a tightening. Opcode 119 is shared with the Broadcast-HR flag (#181), so an opcode-only clause cannot say "this key and no other" — and the clause this replaces admitted ANY device-config key whenever the Broadcast-HR opt-in happened to be on. DeviceConfigWriteGate.admitsSend parses the key name out of the body and admits exactly two keys, each only under its own opt-in, with enable_raw_data_w_ecg additionally requiring the strap to have attested itself an MG over DIS (Whoop5Variant.isMG; unknown is not MG, and a plain 5.0 has no electrodes). The other five enumerated keys are refused unconditionally and SET_FF_VALUE(120) is refused outright. It is one pure predicate that the send path itself consults, the same discipline DeviceConfigReadProbe.isReadOnlyOpcode established, so the tests that prove what it rejects are proving it about the real wire path: 119-with-this-key is the only admitted pair out of all 256 opcodes, under every combination of the two opt-ins. Reversible in one tap: the two directions differ only in the value byte. Also fixed, because it decides whether a negative sweep means anything: - The candidate sweep routed each name to ONE verb by an author's guess at its namespace. The namespaces are proven separate on hardware (128 asked for a device-config key answers FAILURE, and 121 asked for a feature-flag key does too), so a name asked through the wrong verb answers FAILURE and is indistinguishable from "no such key". Every candidate now goes through every answering verb, the report shows which verb said what per name, a name counts as absent only when all verbs agree, and the arithmetic stays in names rather than round-trips. The step cap moves 128 -> 320 so a doubled sweep is not silently truncated. - The sweep can now be run even when enumeration succeeds, as an explicit second menu action. Enumeration lists what the firmware HOLDS, which is not everything it would ACCEPT, and it says nothing at all about the feature-flag namespace where most of the catalogue is aimed. Off by default: it costs one round-trip per name per verb. - Device-config values are not all one byte. max_collection_backlog reads "0.0", which the single-byte reader reported as '0'. ValueResponse.stringValue reads the whole NUL-terminated ASCII value, and the read-back comparison uses it. Swift and Kotlin twins throughout; new UI copy ships with its translations in every locale the i18n gate tracks.
04a5fff to
c343386
Compare
ryanbr
left a comment
There was a problem hiding this comment.
Reviewed the second commit. The gate design is right and I checked the safety claims rather than taking them.
Verified
The claim about the clause this replaces is accurate. On main today it is command == .setDeviceConfig && PuffinExperiment.broadcastHrEnabled — opcode-only, with the key constraint living in the one call site rather than in the gate. Worth stating precisely so it is not overread: it is a latent widening, not a live bug, because nothing currently sends 119 with another key. But the gate did not express the constraint, and now it does. That is a tightening on its own merits and would be worth landing even without the ECG experiment.
The gate is real, not a copy of the rule. admitsSend(opcode:payload:ecgGateOptIn:isMG:broadcastHrOptIn:) is called inside send() against the actual payload argument, so the tests are proving something about the wire path. 120 is unreachable by construction (guard opcode == setDeviceConfigValueCmd), isWritableKey closes with default: return false, and keyName(inSendPayload:) requires the 0x01 b3 byte, printable ASCII, and NUL for every byte after the name — so a padded or truncated body fails to parse rather than falling through. Both platforms carry the predicate, and the Swift one is in Packages/, so swift-packages actually runs its tests.
The read-back-is-the-proof design is the right response to what the SELECT_WRIST pair showed: a SUCCESS ack is not evidence that state moved. Reporting a SUCCESS-with-unmoved-value as unchanged is the correct verdict, and it is the same discipline the #896 verdict fix applies.
Candidate sweep routing (your fix 1) is a genuinely good catch and the same class of defect as the verdict bug: a wrong-verb FAILURE is indistinguishable from "no such key", so a negative sweep was worthless for precisely the names it needed to settle. Asking every verb and calling a name absent only when all agree is the right shape.
Two mechanical things
Six conflicts, all strings.xml. #902 landed 22 keys appended before </resources> in every locale file, and this appends five at the same anchor. Textual and trivial — keep both blocks — but a real rebase, not a stale flag this time. I test-merged to confirm.
No CI has run on the branch at all — gh pr checks reports nothing, so do not read the absence as green. Worth a push after the rebase, and note that android.yml is enabled now, so you will get a real Android build and unit-test run rather than the Swift half only.
The one risk the description does not cover
The persistence warning is good and correctly prominent — "STAYS ON YOUR STRAP … closing NOOP won't undo it" is exactly the right register, and better than most copy in the app.
What it does not address is that the flag is reversible and data recorded while it was on is not. The strap holds history until NOOP acks it, and the ack advances a trim cursor (HISTORY_END carries it, HISTORICAL_DATA_RESULT advances it). Flash is therefore a bounded buffer of unsynced history. A key whose name is literally enable_raw_data_w_ecg is, on the natural reading, asking the strap to store more — and if that competes for the same flash, history that has not been synced yet gets trimmed earlier than it otherwise would.
I cannot show that it does; the flag's effect is exactly what is unknown. But the asymmetry is worth a sentence, because "reversible in one tap" is currently doing work it can only do for the flag itself. Two cheap mitigations, either is fine:
- say in the copy that the experiment should be short — turn on, listen, turn off — rather than left enabled;
- or suggest syncing before turning it on, so there is less unacked history exposed if it does increase storage pressure.
A user who flips this, forgets, and wears the strap for a week is the case worth designing the copy against.
Smaller
- Strings are localised at the point of writing, in both catalogues. Worth noting because #896 is currently red on exactly this and the copy there carries the non-medical framing.
- The byte-13 characterisation is now correct — arg-echo refuted, blanket payload-echo refuted, read-back still an inference because a per-opcode constant fits. Nothing in the PR leans on it, which is the right call.
- Stacked on #898, so this needs that to land first; the description says so and the commit split is clean.
Drift only — upstream #973/#960/#962 rewrote the same copy surfaces this branch touches. Three kinds of conflict, resolved three different ways: 1. `Localizable.xcstrings` — 3-way SEMANTIC merge on the `strings` dict, not a textual one. Keeping both sides of an .xcstrings conflict duplicates top-level keys and JSON parsing hides it. base 3274, ours 3280, theirs 3294 -> merged 3300; ours-add 6, theirs-add 20, theirs-edit 31, conflicts 0, duplicate keys at any nesting level 0, and the diff against upstream/main is 312 insertions with zero deletions, so no upstream string was dropped. 2. All six Android `strings.xml` — tail-append collisions. Both sides kept, then each file re-parsed as XML and every `name` counted: 1746 x4 / 1745 / 1703, zero duplicates in any locale. A duplicated `name` silently drops the earlier string, so it is checked rather than assumed. 3. The four `DeviceConfigReadProbe` files — this branch is stacked on #898 (`776e2605`), which is itself a superset of upstream #914, so this branch's version was taken. Verified they are byte-identical to the pre-merge branch afterwards, i.e. nothing from #914 was needed that was not already present. Verified: WhoopProtocol 480/480, android `./gradlew testFullDebugUnitTest` 3268 tests / 0 failures (5 skipped), Tools/i18n_audit.py --ci clean (no new gap in any locale), and the macOS app target BUILD SUCCEEDED — this branch touches Strand/BLE and Strand/Screens, which no default CI job compiles.
Stacked on #898 — its commit is the first one here. Review only the second commit; once #898 lands this rebases to just that.
What this is
#891 records the standing puzzle: on a WHOOP 5 MG all three TOGGLE_LABRADOR commands answer
SUCCESS(1)and produce zero ECG packets in a 30-second listen. This adds the one experiment that can move it, and deliberately nothing else.The key was not guessed. #898's read-only 115/116 enumeration asked the strap to list its own device-config keys. It answered
revision=1 count=7and walked to a cleanindex=255 validKey=falseterminator:Six of those seven were unknown to this codebase. On that strap
enable_raw_data_w_ecgreads'0'. A key pairing "raw data" with "ecg", sitting at'0'on hardware whose ECG commands accept and emit nothing, is the leading candidate for the gate.Whether flipping it produces ECG data is UNKNOWN. It is a hypothesis the strap's own enumeration made testable — no claim is made that writing it replicates any other client's behaviour. A confirmed
'1'with still no packets removes the leading hypothesis from #891 and is exactly as publishable as a positive.Config writes on this firmware are demonstrated rather than assumed: running the existing
enable_r22_*sequence (#174) movedenable_sig12from'2'(0x32) to'1'(0x31), confirmed by a 121 read either side.The read-back is the proof, never the ack
The write's own
COMMAND_RESPONSEis recorded and not believed. ASUCCESSresult byte is not evidence that state changed —SELECT_WRISTreturns SUCCESS for a no-op and FAILURE for a real change. Every write is followed byGET_DEVICE_CONFIG_VALUE(121)on the same key, and only the value that comes back is reported. A SUCCESS ack whose value did not move is reported asunchanged, not as success.Framing owed to @ryanbr on #891.
What the echoed byte in those toggle replies actually means is still open. Arg-echo is refuted, and blanket payload-echo is refuted, but "read-back of stored state" remains an inference — a per-opcode handler echoing a constant fits the observations equally well. Nothing here relies on settling that.
Safety: the allowlist is key-aware now, which is a tightening
Opcode 119 is shared with the Broadcast-HR flag (#181), so an opcode-only clause cannot express "this key and no other" — and the clause this replaces admitted ANY device-config key whenever the Broadcast-HR opt-in happened to be on.
DeviceConfigWriteGate.admitsSendparses the key name out of the body and admits exactly two keys, each only under its own opt-in:whoop_live_hr_in_adv_ind_pktenable_raw_data_w_ecgWhoop5Variant.isMG.unknownis not MG, so an unattested strap is refused exactly like a plain 5.0 — which has no electrodes. The other five enumerated keys are refused unconditionally; their effects are undocumented and nothing here has any reason to move them.SET_FF_VALUE(120)is refused outright — the R22 sequence keeps its own separate clause.It is one pure predicate that the send path itself consults, the same discipline
DeviceConfigReadProbe.isReadOnlyOpcodeestablished, so the tests prove it about the real wire path rather than a copy of the rule:Reversible in one tap — the two directions differ only in the value byte.
Three fixes that decide whether a negative sweep means anything
1. The candidate sweep routed each name to ONE verb. It picked the verb from an author's guess at the name's namespace. But the namespaces are proven separate on hardware: 128 asked for a device-config key answers FAILURE, and 121 asked for a feature-flag key answers FAILURE. So a name asked through the wrong verb answers FAILURE and is indistinguishable from "no such key" — which would have made a negative sweep worthless for exactly the names it most needed to settle. Every candidate now goes through every answering verb; the report shows which verb said what per name; a name counts as absent only when all verbs agree; the arithmetic stays in names rather than round-trips. Step cap 128 -> 320 so a doubled sweep is not silently truncated.
2. The sweep can now run even when enumeration succeeds, as an explicit second menu action. Enumeration lists what the firmware holds, which is not everything it would accept, and it says nothing at all about the feature-flag namespace — where most of the catalogue is aimed, including the
sig<N>line the strap's own console tag (SIGPROC: generated a valid SPO2 during sleep) points at. Off by default: one round-trip per name per verb.3. Device-config values are not all one byte.
max_collection_backlogreads"0.0". The single-byte reader reported that as'0'and lost the rest.ValueResponse.stringValuereads the whole NUL-terminated ASCII value (stopping at the first NUL, so envelope padding stays out), and the read-back comparison uses it.A note on the 16 flag values
The feature-flag values this probe reads back are a round-trip of NOOP's own writes, not WHOOP defaults —
enable_sig12reads'2'because an older build wrote0x32before #522 changed it to0x31. They say nothing about what the strap ships with.How to run it
Settings -> Experimental - WHOOP 5/MG -> ECG raw-data gate (WHOOP MG only) -> Turn gate on ('1'). NOOP writes the key, reads it back, and reports the value the strap actually stores. Turn gate off ('0') reverses it in one tap.
The full name sweep is Devices -> the device row menu -> Device-config read probe -> Send probe + full name sweep.
Tests
Swift
swift test(Packages/WhoopProtocol) and AndroidtestFullDebugUnitTest. New UI copy ships with translations in every locale the i18n gate tracks;Tools/i18n_audit.py --cipasses clean on both platforms.