Make the R22 deep-data unlock actually reversible: a disable sequence with a mandatory read-back (#174) - #932
Conversation
ryanbr
left a comment
There was a problem hiding this comment.
The staged probe is the right design, and tightening opcode 120 from "any key, any value while the
opt-in is on" to key- and value-aware is worth the PR on its own. Thanks for building both app targets —
nothing in CI covers them.
One blocker: the toggle-off path can't work.
deepDataEnabled is @AppStorage bound straight to the pref, so flipping the switch off writes false
immediately. .onChange then raises the dialog, and the confirm button calls disableWhoop5DeepData() —
which opens with guard PuffinExperiment.deepDataEnabled. The pref is already false, so it returns at
the guard.
The user is shown "NOOP can write the off value to all 16 now and read each one back", taps Clear
flags on strap, and nothing happens. The only trace is a strap-log line telling them to turn the toggle
back on. Android is identical (if (!puffinExperiment.isDeepDataEnabled) at WhoopBleClient:5728).
That is the same shape as the defect this PR fixes: UI promising an undo that never runs. It also makes
the second flow in "How to run it" dead, and the button path is the only one that works.
The allowlist already has the pattern for the fix — it admits the read-back opcode on
r22DisableRun != nil rather than on the pref. Doing the same for opcode 120 restricted to
featureFlagOffValue would let a run in flight clear the flags regardless of the toggle, which is
exactly when clearing them is wanted. Then the guard in disableWhoop5DeepData() can go.
One nit, not blocking. In nextStep(), a key list without the probe key sets probePassed = true and
writes all sixteen unprobed. Unreachable today — both call sites use the no-arg init — but it is
fail-open on a write path whose whole safety argument is the probe, and probePassed = true records a
probe that never ran. Refusing would be truer to the design.
Also needs a rebase; main has moved several times today.
The evidence discipline is the best part of this and I would not want it lost in the fix: the "Not
established" row, keeping the disable_pip_r26_packets polarity and the '2' → '1' correction in
view rather than smoothing them, and choosing the one probe key with a hardware demonstration behind it
so a failure is attributable to the value.
The Settings copy has said since #174 that the R22 unlock is "reversible". That was true about the hardware and false about the app: NOOP shipped an enable button and nothing that writes the flags back, so the only ways out were the official WHOOP app or a factory reset. Turning the switch off only gated future sends — it left all sixteen flags set on the strap. This adds the undo, and verifies it by reading the strap rather than by believing an ack. What it writes `Whoop5Config.disableR22Sequence` is the same sixteen keys in the same order as the enable sequence, each carrying `featureFlagOffValue` = ASCII '0' (0x30). Master flag first, so a run interrupted by a disconnect leaves the strap nearer off than it started. On the off value, precisely: 0x30 is CONFIRMED as this firmware's off value in the device-config namespace — `setBroadcastHr(false)` has written it to `whoop_live_hr_in_adv_ind_pkt` through SET_DEVICE_CONFIG_VALUE(119) since independently reads '0' on an MG whose ECG is idle. It is INFERRED for SET_FF_VALUE(120) by shared convention: identical body layout, identical ASCII-digit value convention, same `[0x01] +` prefix, differing only in opcode and body length. Writes through 120 demonstrably change stored state — the enable sequence moved `enable_sig12` from '2' to '1', confirmed by a GET_FF_VALUE(128) read either side. What is NOT claimed: no feature flag has ever been observed holding '0'; the only values ever read back in that namespace are '1' and '2', and those are a round-trip of NOOP's own writes. `disable_pip_r26_packets` is written '2' despite being named `disable_*`, and `enable_sig12` was corrected '2'→'1' from a real capture, so the official app picks between '1' and '2' per flag rather than using one canonical true. Tri-state semantics stay unestablished. So the run tests the byte instead of asserting it `R22DisableReport` is staged. It writes '0' to ONE flag, reads it back, and declines to touch the other fifteen unless the strap stopped reporting the old value. The probe key is `enable_sig12` — the only key with a hardware demonstration that a write to it moves stored state, so a failure there is attributable to the VALUE rather than to the key or the verb. Read-back separates three outcomes that a boolean "did it work" would blur: value '0' (cleared), FAILURE (no stored value at all — nothing predicts it), or the old value (write refused). Enumeration cannot substitute: '0' is a stored value, so a cleared key stays listed, and the 117/118 walk carries no value field. Every write ack is recorded and not believed. SELECT_WRIST returns SUCCESS for a no-op and FAILURE for a real mutation on this firmware, so only the value a 128 read returns is reported as state (#907/#891 discipline). Also in here - `FeatureFlagWriteGate` tightens the send allowlist for opcode 120. It was opcode-only — any feature-flag key with any value travelled while the deep-data opt-in happened to be on. It is now key- and value-aware: the sixteen R22 keys, each only for its own enable value or the off value. Same weakness #907 closed on opcode 119. - The toggle gets an `.onChange` that offers the disable instead of silently writing nothing. Broadcast HR has had one since #181. - The card said "15" while the sequence carried 16, so it declared success a flag early. Threshold and number now come from the sequence. - A disable run's acks no longer tick the enable counter upward. - An interrupted run is rendered, not dropped: it has already written, so the user is told how far it got and which keys are still set. Verification Swift `swift test` in Packages/WhoopProtocol: 454 tests, 0 failures (24 new). Android `testFullDebugUnitTest`: 3226 tests, 0 failures across 392 classes, counted from the JUnit XML rather than the wrapper exit code (25 new). `assembleFullDebug` clean. macOS `Strand` and iOS `NOOPiOS` both build. `Tools/i18n_audit.py --ci` passes; new copy is translated in every focus locale and in it/ru/zh-Hans/zh-Hant so the extra-locale ratchet does not move. NOT verified on hardware: whether the strap accepts '0' for a feature flag, and whether clearing the flags makes the deep records stop. The first is what the probe stage measures on first run; the second needs a wear-and-sync afterwards and is called out in the UI and the report rather than claimed.
…#174) The disable sequence worked only from the explicit "Turn deep data back off" button. The path a user actually reaches for — flipping the Settings switch OFF and confirming "Clear flags on strap" — was dead, for two independent reasons, and reported nothing either way. 1. `deepDataEnabled` is @AppStorage bound straight to the switch, so flipping it off writes the pref false BEFORE `.onChange` raises the confirmation dialog. By the time the user confirmed, `disableWhoop5DeepData()` returned at its own `guard PuffinExperiment.deepDataEnabled`, and the send allowlist would have refused all sixteen writes anyway. The explicit button worked only because it renders while the pref is still true. 2. The disable report rendered INSIDE `if deepDataEnabled`, so even a working run would have hidden its progress line and its whole read-back table for exactly the run a user is most likely to start. `FeatureFlagWriteGate.admitsSend` is split into two predicates with different gates, following the shape the allowlist already used for the 128 read-back (`isReadBackOpcode(...) && r22DisableRun != nil`): - `admitsEnableWrite` — opcode 120, an R22 key, that key's own ENABLE value, while the deep-data opt-in is on. - `admitsDisableWrite` — opcode 120, an R22 key, `featureFlagOffValue` only, while a disable run is in flight. Gating the undo on the run rather than the pref is also strictly narrower in the other direction: with the opt-in merely left on, no off value reaches the wire unless a run is genuinely walking its plan. Splitting them makes the invariant exact rather than approximate — an off value on the wire means a disable run is in flight — where one predicate admitting either value under either gate could not say that. The now-redundant `deepDataEnabled` guard in `disableWhoop5DeepData()` is deleted on both platforms. Also fail closed in `nextStep()`. A key list without the probe key planned a blanket sixteen-write and set `probePassed = true`: fail-open on the one write path whose entire safety argument is the probe, writing an inferred value to every persistent flag without testing it on one, and recording a passed gate for a probe that never ran. It now refuses, marks every key skipped, and reports the new `probeUnavailable` verdict. Unreachable from either shipped call site, but not a defensible default whatever the current call sites do. Swift `swift test --filter R22DisableTests`: 31 tests, 0 failures. Kotlin `testFullDebugUnitTest --tests com.noop.protocol.R22DisableTest`: 32 tests, 0 failures (JUnit XML). Both suites gain a direct regression test that the off value is admitted with the opt-in OFF while a run is in flight, and that the two directions are disjoint on value.
6aa61c6 to
a9713de
Compare
|
You are right, and the diagnosis was exact. I reproduced it before changing anything: The gate.
Then the One thing I want to flag because it is a change beyond what you asked for: I also stopped Gating on the run is also strictly narrower than the pref in the direction that matters for a write path: with the opt-in merely left switched on, no off value can reach the wire at all unless a run is genuinely walking its plan. A second half of the defect, which fell out of fixing the first. The disable report rendered inside I do not think I would have found that one without your diagnosis; it is the same pref, one layer up. The fail-open nit — fixed, and thank you for not letting it go as "unreachable". Verified
New tests worth naming: Still not established, unchanged by any of this: whether |
Upstream #973 (haptics copy) appended a string to all six Android catalogues at the same place this branch appends the R22-disable strings, so every conflict was a tail-append collision, not a semantic one. Resolved by keeping BOTH sides in all six files, then verifying the thing that makes "keep both" safe or unsafe: each file re-parsed as XML and every `string`/`plurals`/`string-array` `name` counted. 1752/1752/1752/1752/1751/1709 names, zero duplicates in any locale. A duplicated `name` is the failure mode here — the later definition silently wins and the earlier string is dead — so it is checked rather than assumed. Tools/i18n_audit.py --ci is clean: no new hardcoded copy, focus locales complete, no new gaps beyond the focus set. Verified: android `./gradlew testFullDebugUnitTest` 3263 tests / 0 failures (5 skipped).
|
Rebased onto The conflict this merge resolved was purely a tail-append collision: #973 appended a haptics string to all six Android catalogues at the same place this branch appends the R22-disable strings. Both sides kept, and then the thing that makes "keep both" safe or unsafe actually checked — every file re-parsed as XML and every
|
The defect
The Settings copy has said since #174 that the R22 unlock is "reversible (it only changes which data the strap chooses to emit)", and
Whoop5Config's header repeated it. That is true about the hardware and false about the app: NOOP shipped an enable button and nothing that writes the flags back. The toggle has never written anything in either direction — it gates sends — so turning it off silently left all sixteen flags set on the strap. The only ways out were the official WHOOP app or a factory reset.This adds the undo, and verifies it by reading the strap rather than by believing an ack.
What it writes
Whoop5Config.disableR22Sequence— the same sixteen keys, same order asenableR22Sequence, each carryingfeatureFlagOffValue= ASCII'0'(0x30). Master flag first, so a run interrupted by a disconnect leaves the strap nearer off than it started, and the two sequences stay trivially diffable.On the off value, precisely. This is the one inferred byte, and the inference is stronger than a guess and weaker than a measurement, so it is worth stating exactly:
0x30is this firmware's off value in the device-config namespace.setBroadcastHr(false)has written it towhoop_live_hr_in_adv_ind_pktviaSET_DEVICE_CONFIG_VALUE(119)since #181, hardware-validated against a Garmin Edge 840.enable_raw_data_w_ecgindependently reads'0'on an MG whose ECG is idle.[0x01] +inner b3 prefix, written with response. Only the opcode (0x77 vs 0x78) and body length (33 vs 40) differ.deviceConfigBody's own doc spells the convention out as "an ASCII digit, '1' = 0x31 / '0' = 0x30".enableR22Sequencemovedenable_sig12from'2'(0x32) to'1'(0x31), confirmed by aGET_FF_VALUE(128)read before and after. A key that can be written is a key that can be rewritten.'0'. Every 128 read ever taken returned'1'or'2', and those sixteen readings are a round-trip of NOOP's own writes. The namespaces are proven separate at the verb level — a key asked through the wrong verb answers FAILURE — so the evidence above is an argument by shared convention, not a measurement of this opcode.And two pieces of counter-evidence kept in view rather than smoothed over:
disable_pip_r26_packetsis written'2'despite being nameddisable_*, andenable_sig12was corrected'2'→'1'from a real capture (#423) — so the official app picks between'1'and'2'per flag rather than using one canonical "true". Tri-state semantics stay unestablished, and nothing here assumes them.So the run tests the byte instead of asserting it
R22DisableReportis staged rather than a blanket sixteen writes:'0'to ONE flag, read it back withGET_FF_VALUE(128).'0'is not how this firmware clears a flag. That is a publishable answer, not a failure.enable wrote X → now Y.The probe key is
enable_sig12: the only key with a hardware demonstration that a write to it moves stored state, so a failure there is attributable to the value rather than to the key or the verb. Every other key would leave that ambiguity open. It is also the flag with least to lose — undocumented effect, gates no stream NOOP reads.Read-back separates three outcomes a boolean "did it work" would blur:
'0'FAILURE(0)unset— no stored value at all. Nothing predicts this; reported as its own stateunchanged— the write was refused or no-opedEnumeration cannot substitute for the read.
'0'is a stored value, not an absence — six of the seven keys the 115/116 device-config walk returned read'0'and were still enumerated — so a cleared key stays listed rather than dropping out. And the 117/118 feature-flag walk carries no value field at all ([revision][index][validKey][key]), so it can only answer presence. 128 is the only verb that can verify a clear, and it answered cleanly for all sixteen flags on a 5/MG.The ack is never the proof. Every write's
COMMAND_RESPONSEis recorded and not believed —SELECT_WRISTreturns SUCCESS for a no-op and FAILURE for a real mutation on this firmware. A SUCCESS ack whose read-back did not move renders asunchanged, never as success. Discipline owed to #907 / #891.On
disable_pip_r26_packets, the one line a reviewer should push back onFifteen keys read as "enable/tune X" and one reads as "disable X", so writing the same byte to all sixteen looks wrong at a glance. It is deliberate. This sequence is defined as the undo of the enable sequence — clear every flag it set — not as "set each key to its semantic opposite". Those are different operations and only the first is well defined here: inverting per key would require knowing what
'1'and'2'each select, and this key is the evidence we do not.So writing
'0'to it is expected to stop PIP R26 packets being suppressed — i.e. let them flow again, which is the pre-R22 behaviour and the correct restoration. The polarity inversion is real, but it lives in what the key means, not in which byte undoes it. It is called out in the report so nobody is surprised that one key's "off" is another feature's "on". A test pins it.What this does NOT do
It does not restore a snapshot. NOOP has never read these values before writing them, so the strap's pre-NOOP state is unknown and unrecoverable. If the firmware's own default for some key is
'1'rather than unset, this writes a value the factory never used. The honest claim is "clears the sixteen flags NOOP set". Making the stronger claim true needs a read-before-write snapshot on the enable path — worth doing, deliberately out of scope here.Also in this PR
FeatureFlagWriteGatetightens the send allowlist for opcode 120. The clause it replaces was opcode-only (command == .setConfig && deepDataEnabled), so any feature-flag key with any value travelled for as long as the deep-data opt-in happened to be on. It is now key- and value-aware: the sixteen R22 keys, each only for its own enable value or the off value. Exactly the weakness Make the ECG gate hypothesis testable: an opt-in enable_raw_data_w_ecg write with a mandatory read-back (#891, #103) #907 closed on opcode 119, and the disable path doubles the number of values going through 120, so it is tightened rather than widened. One pure predicate the send path itself consults, so the tests prove it about the real wire path..onChangeon the toggle, offering the disable instead of silently writing nothing. Broadcast HR has had one since [android] Today HR chart: arbitrary x-axis times, no time in the drag readout #181; this one was missing.enable_sig12, WHOOP 5.0 raw HCI capture — where to submit? (docs reference #174, which doesn't exist) #103), so it declared success one flag early and named a count that had drifted. Threshold and number now both come fromenableR22Sequence.count.Verification
swift test(Packages/WhoopProtocol)testFullDebugUnitTestassembleFullDebugStrand/ iOSNOOPiOSapp-build.ymlis disabled, so this was run locally)Tools/i18n_audit.py --ciThe pure report is order-dependent and CoreBluetooth-free, so the whole verdict table — including the case where the strap refuses
'0'— is covered without hardware.Not verified on hardware, and deliberately not claimed:
'0'for a feature flag. This is what the probe stage measures on first run. A rejection is reported as a finding, with fifteen keys untouched.How to run it
Settings → Experimental · WHOOP 5 / MG → Turn deep data back off. Or flip the Unlock WHOOP 5/MG deep data (R22) switch off and accept the prompt. Needs the full encrypted bond; unlike the enable it does not require the strap worn — the on-wrist gate exists because the R22 stream is on-wrist only, and turning a feature off should not require strapping the device back on.
Refs #174 (the original unlock), #890 / #907 (the read-back-not-the-ack pattern), #103 / #423 (the 16th flag and its value correction), #181 (the hardware-validated
0x30precedent).