From 18da10112749f33da0815f563555675c6bbb037f Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:48:30 -0400 Subject: [PATCH 1/2] Make the R22 deep-data unlock actually reversible (#174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Sources/WhoopProtocol/R22Disable.swift | 484 +++++++++++++++ .../Sources/WhoopProtocol/Whoop5Config.swift | 122 +++- .../WhoopProtocolTests/R22DisableTests.swift | 320 ++++++++++ Strand/BLE/BLEManager.swift | 194 +++++- Strand/BLE/LiveState.swift | 8 + Strand/BLE/PuffinExperiment.swift | 9 +- Strand/Resources/Localizable.xcstrings | 572 ++++++++++++++++++ Strand/Screens/SettingsView.swift | 101 +++- .../main/java/com/noop/ble/WhoopBleClient.kt | 193 +++++- .../main/java/com/noop/protocol/R22Disable.kt | 393 ++++++++++++ .../java/com/noop/protocol/Whoop5Config.kt | 57 ++ .../main/java/com/noop/ui/SettingsScreen.kt | 122 +++- .../app/src/main/res/values-de/strings.xml | 11 + .../app/src/main/res/values-es/strings.xml | 11 + .../app/src/main/res/values-fr/strings.xml | 11 + .../src/main/res/values-pt-rPT/strings.xml | 11 + .../app/src/main/res/values-zh/strings.xml | 11 + android/app/src/main/res/values/strings.xml | 11 + .../java/com/noop/protocol/R22DisableTest.kt | 353 +++++++++++ 19 files changed, 2963 insertions(+), 31 deletions(-) create mode 100644 Packages/WhoopProtocol/Sources/WhoopProtocol/R22Disable.swift create mode 100644 Packages/WhoopProtocol/Tests/WhoopProtocolTests/R22DisableTests.swift create mode 100644 android/app/src/main/java/com/noop/protocol/R22Disable.kt create mode 100644 android/app/src/test/java/com/noop/protocol/R22DisableTest.kt diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/R22Disable.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/R22Disable.swift new file mode 100644 index 0000000000..1e6c641f40 --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/R22Disable.swift @@ -0,0 +1,484 @@ +import Foundation + +/// #174: the key-aware allowlist for `SET_FF_VALUE` (120 / 0x78) — the feature-flag WRITE verb the R22 +/// enable and disable sequences share. +/// +/// ## Why this exists +/// +/// Before this file the 5/MG send path admitted opcode 120 on an opcode-only clause: +/// `command == .setConfig && PuffinExperiment.deepDataEnabled`. That admits **any** feature-flag key with +/// **any** value for as long as the deep-data opt-in happens to be on. The sixteen R22 keys are the only +/// ones NOOP has any business writing, and the disable path is about to double the number of values that +/// travel through that opcode, so the clause is tightened at the same time rather than widened. +/// +/// This is the same discipline `DeviceConfigReadProbe.isReadOnlyOpcode` established for the read probes: +/// one pure predicate that the BLE send path itself consults, so a unit test proving the predicate rejects +/// something is proving it about the real wire path and not about a parallel copy of the rule. +/// +/// ## What it admits +/// +/// | opcode | key | value | gate | +/// |---|---|---|---| +/// | 120 `SET_FF_VALUE` | one of `Whoop5Config.enableR22Sequence` | the enable value for that key, or `featureFlagOffValue` | deep-data opt-in | +/// | anything else | — | — | refused | +/// +/// A value outside that pair is refused even for an R22 key: nothing in NOOP has a reason to write a third +/// value, and admitting one would mean a typo could put an arbitrary byte into persistent strap config. +/// `SET_DEVICE_CONFIG_VALUE` (119) is refused outright — it keeps its own clause for the Broadcast-HR flag. +/// +/// Pure: no CoreBluetooth, no I/O, no preferences. The app layer supplies the opt-in boolean. The Kotlin +/// twin is `com.noop.protocol.FeatureFlagWriteGate` — keep them byte-identical. +public enum FeatureFlagWriteGate { + + // MARK: - Opcodes + + /// `SET_FF_VALUE` (120 / 0x78) — the feature-flag write verb. The only opcode this gate admits. + public static let setFeatureFlagValueCmd: UInt8 = 120 + + /// `SET_DEVICE_CONFIG_VALUE` (119 / 0x77) — the OTHER namespace's write verb. Named here for exactly + /// one reason: so `admitsSend` can be proved to refuse it. + public static let setDeviceConfigValueCmd: UInt8 = 119 + + /// `GET_FF_VALUE` (128 / 0x80) — the read verb the mandatory post-write read-back uses. Read-only, and + /// already in `DeviceConfigReadProbe.readOnlyOpcodes`. + public static let getFeatureFlagValueCmd: UInt8 = 128 + + // MARK: - Keys and values + + /// The sixteen keys, taken from the enable sequence rather than restated — one list, one truth. + public static var r22Keys: [String] { Whoop5Config.enableR22Sequence.map(\.name) } + + /// The value the enable sequence writes for `key`, or nil when `key` is not an R22 flag. + public static func enableValue(for key: String) -> UInt8? { + Whoop5Config.enableR22Sequence.first { $0.name == key }?.value + } + + /// Whether `value` is one NOOP is allowed to write for `key`: that key's own enable value, or the + /// documented off value. Anything else is refused. + public static func isWritableValue(_ value: UInt8, for key: String) -> Bool { + guard let on = enableValue(for: key) else { return false } + return value == on || value == Whoop5Config.featureFlagOffValue + } + + // MARK: - Body parsing + + /// Width of the key-name field in a feature-flag body (`Whoop5Config.payloadBody` NUL-pads to this). + public static let nameFieldBytes = 32 + + /// Total length of the feature-flag body: 32-byte name, value byte, 7 zero bytes. + public static let bodyBytes = 40 + + /// The key name and value carried by a `SET_FF_VALUE` payload, or nil when the payload is not shaped + /// like one. + /// + /// The payload the send path holds is `[0x01] + payloadBody(...)`: the inner b3 byte, then the 32-byte + /// NUL-padded name, then the value, then 7 zeroes. A name is only returned when it is printable ASCII + /// and the remainder of the field is genuine NUL padding — so a body that is short, mis-shaped, or + /// carrying binary in the name field yields nil and is refused rather than guessed at. Mirrors + /// `DeviceConfigWriteGate.keyName(inSendPayload:)` on the other namespace. + public static func keyAndValue(inSendPayload payload: [UInt8]) -> (key: String, value: UInt8)? { + guard payload.count >= 1 + bodyBytes, payload[0] == 0x01 else { return nil } + let field = Array(payload[1..<(1 + nameFieldBytes)]) + var name: [UInt8] = [] + for b in field { + if b == 0 { break } + guard (0x20...0x7E).contains(b) else { return nil } + name.append(b) + } + guard !name.isEmpty else { return nil } + // Everything after the name must be NUL, or this is not a NUL-padded name field. + for i in name.count.. Bool { + guard deepDataOptIn else { return false } + guard opcode == setFeatureFlagValueCmd else { return false } + guard let (key, value) = keyAndValue(inSendPayload: payload) else { return false } + return isWritableValue(value, for: key) + } + + /// The read verb the post-write verification is allowed to send, and only that one. + public static func isReadBackOpcode(_ opcode: UInt8) -> Bool { opcode == getFeatureFlagValueCmd } +} + +// MARK: - Report + +/// #174: one R22 **disable** run — write `'0'` to the sixteen feature flags, then read every one of them +/// back and report the value the strap actually stores. +/// +/// ## The shape of the run, and why it is not just sixteen writes +/// +/// `Whoop5Config.featureFlagOffValue` explains why `'0'` is an inference: it is the confirmed off value in +/// the device-config namespace and shares that namespace's whole wire idiom, but no feature flag has ever +/// been *observed* holding it. So the run is staged, and the first stage is an experiment: +/// +/// 1. **Probe.** Write `'0'` to ONE flag, then read it back with `GET_FF_VALUE(128)`. +/// 2. **Gate.** If the strap did not stop reporting the old value, STOP. Fifteen keys are left untouched +/// and the report says `'0'` is not how this namespace spells off — a real, publishable answer. +/// 3. **Clear.** Only on a good probe, write `'0'` to the remaining fifteen. +/// 4. **Verify.** Read all sixteen back and tabulate. +/// +/// The staging is the point. A blanket sixteen-write on an inferred byte would leave a user unable to tell +/// "it worked" from "the strap ignored all of it", and would move fifteen keys to find out. +/// +/// ## The ack is never the proof +/// +/// Every write's own `COMMAND_RESPONSE` is recorded and **not believed**. `SELECT_WRIST` on this firmware +/// returns SUCCESS for a no-op and FAILURE for a real mutation, so a result byte says nothing reliable +/// about whether state moved. Only the value that comes back from a 128 read is reported as state, and a +/// SUCCESS ack whose read-back did not move is reported as `unchanged`, not as success. (Discipline owed +/// to #907 / #891.) +/// +/// ## Three outcomes, kept apart +/// +/// The read-back separates possibilities that a boolean "did it work" would blur — see the table on +/// `Whoop5Config.featureFlagOffValue`. `.cleared` (the key now holds `'0'`) and `.unset` (the key no longer +/// has a stored value at all, so 128 answers FAILURE) are BOTH successes, and the difference is the most +/// interesting thing this path can learn about the firmware, so it is reported rather than collapsed. +/// +/// Pure and order-dependent (`nextStep` → `note…` → `nextStep` → …), so `swift test` covers the whole run — +/// plan, gate, verdicts, rendering — without a strap. Kotlin twin: `R22DisableReport` in +/// `android/…/protocol/R22Disable.kt`; the rendered text is byte-identical across platforms so a shared +/// strap log reads the same either side. +public struct R22DisableReport: Equatable, Sendable { + + /// The flag the probe stage writes first. + /// + /// `enable_sig12` is chosen because it is the ONLY key with a hardware demonstration that a write to it + /// changes stored state: running the enable sequence moved it from `'2'` (0x32) to `'1'` (0x31), + /// confirmed by a `GET_FF_VALUE(128)` read before and after (#423, #103). That is what makes it a clean + /// instrument. If a `'0'` write to *this* key does not move the stored value, the failure 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 the least to lose: its effect is undocumented and it gates + /// no stream NOOP reads. + public static let probeKey = "enable_sig12" + + /// Which stage of the run a step belongs to. + public enum Stage: String, Equatable, Sendable { + /// The single write that tests whether `'0'` is accepted at all. + case probeWrite + /// The read-back of the probe write. The gate for everything after it. + case probeRead + /// One of the remaining fifteen writes. + case clearWrite + /// A final read-back of one key. + case verifyRead + } + + /// One planned round-trip. + public struct Step: Equatable, Sendable { + public let opcode: UInt8 + public let key: String + public let stage: Stage + public init(opcode: UInt8, key: String, stage: Stage) { + self.opcode = opcode + self.key = key + self.stage = stage + } + /// Whether this step puts a value on the wire (as opposed to reading one back). + public var isWrite: Bool { stage == .probeWrite || stage == .clearWrite } + } + + /// What one key ended up in. `.cleared` and `.unset` are both successes. + public enum KeyOutcome: String, Equatable, Sendable { + /// Read back as `'0'`: the key exists and now holds the off value. + case cleared + /// The strap answered FAILURE for the key: it no longer has a stored value. The stronger result. + case unset + /// Read back as something other than `'0'` — the write did not take. + case unchanged + /// The strap answered, but did not echo the key, so no value can be claimed. + case notClaimed + /// No reply inside the step's window. + case silent + /// A reply arrived that could not be decoded (CRC, envelope, short record). + case undecodable + /// The run stopped before this key was written. + case skipped + + /// Whether this outcome means the flag is no longer set to its R22 value. + public var isSuccess: Bool { self == .cleared || self == .unset } + } + + /// The overall verdict. + public enum Verdict: String, Equatable, Sendable { + /// The run has not finished. + case pending + /// Every one of the sixteen came back cleared or unset. + case allCleared + /// Some cleared, some did not. + case partial + /// The probe read-back came back showing the OLD value: `'0'` is not how this firmware clears a + /// feature flag. The one verdict that is a genuine finding about the off value. + case offValueRejected + /// The probe read-back produced no usable answer — silent, undecodable, or the key was not echoed. + /// Distinct from `offValueRejected` on purpose: nothing was learned about the off value, so this + /// must not be reported as evidence against it. + case probeInconclusive + /// The run was abandoned (disconnect, or the caller stopped it). + case abandoned + } + + /// One recorded read-back. + public struct Reading: Equatable, Sendable { + public let key: String + public let stage: Stage + /// The value byte, only when the strap echoed the key in a 32-byte name field. nil is "not + /// claimed", never "zero". + public let value: UInt8? + public let resultCode: Int? + public let recordHex: String + public let outcome: KeyOutcome + public init(key: String, stage: Stage, value: UInt8?, resultCode: Int?, + recordHex: String, outcome: KeyOutcome) { + self.key = key + self.stage = stage + self.value = value + self.resultCode = resultCode + self.recordHex = recordHex + self.outcome = outcome + } + } + + // MARK: Inputs + + /// The keys to clear, in order. Taken from the enable sequence so the two can never drift. + public let keys: [String] + + // MARK: State + + private var cursor = 0 + private var plan: [Step] = [] + private var planBuilt = false + /// Per-key outcome, keyed by name. + public private(set) var outcomes: [String: KeyOutcome] = [:] + public private(set) var readings: [Reading] = [] + public private(set) var writeAcks: [String: Int] = [:] + public private(set) var trace: [String] = [] + public private(set) var verdict: Verdict = .pending + /// True once the probe read-back has been seen and was good. + public private(set) var probePassed = false + /// True once the run has decided to stop early. + private var stopped = false + + public init(keys: [String] = Whoop5Config.enableR22Sequence.map(\.name)) { + self.keys = keys + trace.append("R22 disable: writing '0' (0x30) to \(keys.count) feature flags via SET_FF_VALUE(120), " + + "each verified with GET_FF_VALUE(128). The write acks are recorded and NOT trusted.") + trace.append("Probe first: \(R22DisableReport.probeKey) is written and read back alone; the other " + + "\(max(0, keys.count - 1)) are only written if the strap stops reporting the old value.") + } + + // MARK: Plan + + /// The next round-trip, or nil when the run is over. + /// + /// The plan is built lazily in two halves so the gate is expressible: the probe pair up front, and the + /// clear+verify tail only once the probe read-back has passed. + public mutating func nextStep() -> Step? { + if plan.isEmpty && !planBuilt { + planBuilt = true + guard keys.contains(R22DisableReport.probeKey) else { + // Defensive: a caller that hands over a key list without the probe key gets a plain + // sequential run rather than a silently skipped gate. + plan = keys.map { Step(opcode: FeatureFlagWriteGate.setFeatureFlagValueCmd, key: $0, stage: .clearWrite) } + + keys.map { Step(opcode: FeatureFlagWriteGate.getFeatureFlagValueCmd, key: $0, stage: .verifyRead) } + probePassed = true + return nextStep() + } + plan = [ + Step(opcode: FeatureFlagWriteGate.setFeatureFlagValueCmd, + key: R22DisableReport.probeKey, stage: .probeWrite), + Step(opcode: FeatureFlagWriteGate.getFeatureFlagValueCmd, + key: R22DisableReport.probeKey, stage: .probeRead), + ] + } + if stopped { return nil } + if cursor >= plan.count { + // The probe pair just finished and passed — extend the plan with the rest. + if probePassed && !plan.contains(where: { $0.stage == .clearWrite }) { + let rest = keys.filter { $0 != R22DisableReport.probeKey } + plan += rest.map { Step(opcode: FeatureFlagWriteGate.setFeatureFlagValueCmd, key: $0, stage: .clearWrite) } + plan += keys.map { Step(opcode: FeatureFlagWriteGate.getFeatureFlagValueCmd, key: $0, stage: .verifyRead) } + } else { + return nil + } + } + guard cursor < plan.count else { return nil } + let step = plan[cursor] + cursor += 1 + return step + } + + // MARK: Notes + + /// Record a write's own ack. Logged, never used to decide anything. + public mutating func noteWriteAck(resultCode: Int?, for step: Step) { + if let resultCode { writeAcks[step.key] = resultCode } + let label = resultCode.map { "\(FeatureFlagProbe.resultLabel($0))(\($0))" } ?? "(unlabelled)" + trace.append("SET_FF_VALUE(120) \(step.key)='0' → ack \(label) — recorded, not proof") + } + + /// Record a decoded read-back and score the key. + public mutating func noteReadBack(_ r: DeviceConfigReadProbe.ValueResponse, for step: Step) { + let value = r.value(for: step.key) + let outcome: KeyOutcome + if r.isUnsupported { + outcome = .unchanged // the verb itself is refused; nothing can be claimed cleared + } else if r.isFailure { + outcome = .unset // no stored value for this key any more + } else if let value { + outcome = value == Whoop5Config.featureFlagOffValue ? .cleared : .unchanged + } else { + outcome = .notClaimed + } + record(Reading(key: step.key, stage: step.stage, value: value, resultCode: r.resultCode, + recordHex: r.recordHex, outcome: outcome), for: step) + } + + /// Record a read-back reply that could not be decoded. + public mutating func noteReadFailure(_ f: DeviceConfigReadProbe.ParseFailure, for step: Step) { + let why: String + switch f { + case .crc: why = "CRC failed — frame rejected (never decoded)" + case .envelope: why = "not a COMMAND_RESPONSE envelope" + case .wrongCommand: why = "COMMAND_RESPONSE for a different command" + case .truncated: why = "record too short to hold a response" + } + trace.append("GET_FF_VALUE(128) \(step.key) → reply not decoded: \(why)") + record(Reading(key: step.key, stage: step.stage, value: nil, resultCode: nil, + recordHex: "(undecodable)", outcome: .undecodable), for: step) + } + + /// Record the strap answering nothing at all. + public mutating func noteTimeout(for step: Step, seconds: Int) { + trace.append("\(step.isWrite ? "SET_FF_VALUE(120)" : "GET_FF_VALUE(128)") \(step.key) → no COMMAND_RESPONSE within \(seconds)s") + guard !step.isWrite else { return } // a silent WRITE is fine; the read-back is what decides + record(Reading(key: step.key, stage: step.stage, value: nil, resultCode: nil, + recordHex: "(no reply)", outcome: .silent), for: step) + } + + /// The link dropped, or the caller stopped the run. + public mutating func noteAbandoned(_ why: String) { + trace.append("Run abandoned: \(why)") + stopped = true + for key in keys where outcomes[key] == nil { outcomes[key] = .skipped } + verdict = .abandoned + } + + /// Score one reading, drive the probe gate, and finalise when the plan is done. + private mutating func record(_ reading: Reading, for step: Step) { + readings.append(reading) + var line = "GET_FF_VALUE(128) \(step.key)" + if let c = reading.resultCode { line += " → result=\(FeatureFlagProbe.resultLabel(c))(\(c))" } else { line += " →" } + if let v = reading.value { line += " value=\(DeviceConfigReadProbe.valueLabel(v))" } + line += " record=[\(reading.recordHex)] ⇒ \(reading.outcome.rawValue)" + trace.append(line) + + // A verify read always sets the key's outcome. A probe read sets it too, and additionally decides + // whether the rest of the run happens at all. + outcomes[step.key] = reading.outcome + + if step.stage == .probeRead { + probePassed = reading.outcome.isSuccess + if !probePassed { + stopped = true + for key in keys where key != step.key { outcomes[key] = .skipped } + // ONLY an unmoved value is evidence against '0'. Silence, an undecodable frame and a reply + // that never echoed the key all mean "no usable answer" and must not be reported as a + // rejection of the off value. + verdict = reading.outcome == .unchanged ? .offValueRejected : .probeInconclusive + trace.append(probeStopExplanation(reading.outcome)) + } else { + trace.append("Probe passed (\(reading.outcome.rawValue)) — '0' moves a feature flag on this " + + "firmware. Clearing the remaining \(max(0, keys.count - 1)).") + } + } + + if !stopped, outcomes.count == keys.count, readings.contains(where: { $0.stage == .verifyRead }) { + let cleared = keys.filter { outcomes[$0]?.isSuccess == true }.count + verdict = cleared == keys.count ? .allCleared : .partial + } + } + + /// The sentence that explains a stopped run, in the terms that make it a result rather than a failure. + private func probeStopExplanation(_ outcome: KeyOutcome) -> String { + switch outcome { + case .unchanged: + return "STOPPED: the strap still reports the old value for \(R22DisableReport.probeKey), so " + + "'0' (0x30) is NOT how this firmware clears a feature flag. The other \(max(0, keys.count - 1)) " + + "flags were left untouched. This is a real finding — please share this report on #174." + case .notClaimed: + return "STOPPED: the strap answered but did not echo \(R22DisableReport.probeKey), so no value " + + "can be claimed either way. Nothing further was written." + case .silent, .undecodable: + return "STOPPED: no usable read-back for \(R22DisableReport.probeKey), so whether the write " + + "landed is unknown. Nothing further was written." + default: + return "STOPPED before clearing the remaining flags." + } + } + + // MARK: Rendering + + /// One-line summary, suitable for a Settings row. + public var summary: String { + let cleared = keys.filter { outcomes[$0]?.isSuccess == true }.count + switch verdict { + case .pending: return "Clearing R22 flags…" + case .allCleared: return "All \(keys.count) R22 flags cleared on the strap (read back, not just acked)." + case .partial: return "\(cleared) of \(keys.count) R22 flags cleared — the rest did not take." + case .offValueRejected: return "The strap refused '0' for \(R22DisableReport.probeKey); the other flags were left alone." + case .probeInconclusive: return "No usable read-back for \(R22DisableReport.probeKey) — nothing further was written." + case .abandoned: return "Disable run interrupted — \(cleared) of \(keys.count) flags confirmed cleared." + } + } + + /// The full copyable report. + public func render() -> String { + var sb = "#174 R22 DEEP-DATA DISABLE — WHOOP 5/MG\n" + sb += "Wrote '0' (0x30) via SET_FF_VALUE(120) and read every key back with GET_FF_VALUE(128).\n" + sb += "The write acks are recorded and NOT treated as proof: on this firmware a SUCCESS result byte\n" + sb += "does not establish that state changed. Only the read-back is reported as state.\n" + sb += "\nVerdict: \(verdict.rawValue) — \(summary)\n" + sb += "\nPer key:\n" + for key in keys { + let outcome = outcomes[key] ?? .skipped + let reading = readings.last { $0.key == key && $0.stage == .verifyRead } + ?? readings.last { $0.key == key } + let was = Whoop5Config.enableR22Sequence.first { $0.name == key } + .map { DeviceConfigReadProbe.valueLabel($0.value) } ?? "?" + let now = reading?.value.map { DeviceConfigReadProbe.valueLabel($0) } + ?? (outcome == .unset ? "(no stored value)" : "(not claimed)") + sb += " \(DeviceConfigReadProbe.padded(key, to: 30)) enable wrote \(was) → now \(now) [\(outcome.rawValue)]\n" + } + sb += "\nExchange:\n" + for line in trace { sb += " " + line + "\n" } + sb += "\n" + R22DisableReport.caveats + return sb + } + + /// The standing caveats, kept in one place so the UI, the log and the report cannot drift. + public static let caveats = """ + What this does and does not establish: + • '0' as the off value is INFERRED. It is the confirmed off value in the device-config namespace + (SET_DEVICE_CONFIG_VALUE/119, hardware-validated on the Broadcast-HR flag, #181) and that namespace + shares this one's whole wire idiom — but no feature flag had ever been observed holding '0' before + this run. The read-back above is what turns that inference into a measurement. + • A cleared flag is not the same as reverted BEHAVIOUR. This reports what the strap STORES. Whether + the deep records actually stop is a separate question, answered by wearing the strap and watching + the type-0x2F deep-buffer capture stop, not by this report. + • This does not restore a snapshot. NOOP never read these values before first writing them, so the + strap's pre-NOOP state is unknown. The honest claim is "cleared the flags NOOP set". + • disable_pip_r26_packets inverts: clearing it is expected to let PIP R26 packets flow AGAIN. That is + the pre-R22 behaviour and is intended. + """ +} diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Config.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Config.swift index 5c11b6cafd..053c85730c 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Config.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Config.swift @@ -16,9 +16,16 @@ import Foundation // then 7 bytes of zero. The command is built with the normal puffin envelope via `puffinCommandFrame` // (cmd 0x78, the inner b3 byte 0x01 carried as the first payload byte, exactly like CLIENT_HELLO). // -// This is reversible — it only changes which data the strap chooses to emit — but it is gated behind an -// explicit opt-in in the app, and on real hardware it can only be written from iOS/Android: macOS -// CoreBluetooth cannot complete the authenticated SMP bond the command characteristic requires. +// It is gated behind an explicit opt-in in the app, and on real hardware it can only be written from +// iOS/Android: macOS CoreBluetooth cannot complete the authenticated SMP bond the command characteristic +// requires. +// +// **Reversing it.** The write only changes which data the strap chooses to emit, and the same opcode that +// sets a flag can clear it — see `disableR22Sequence` below, and `R22DisableReport` for the write + +// mandatory read-back that proves what the strap actually stores. For the first year of this file that +// claim was made in a comment with nothing implementing it; the value byte that spells "off" is still +// inferred rather than observed, which is why the disable path tests it on one key before touching the +// rest. Read `featureFlagOffValue` before relying on any of this. public enum Whoop5Config { /// SET_CONFIG / SET_FF_VALUE command opcode. @@ -105,4 +112,113 @@ public enum Whoop5Config { frame(flag: flag, seq: UInt8((Int(firstSeq) + idx) & 0xFF)) } } + + // MARK: - Turning it back off (#174) + + /// The value byte written to clear a feature flag: ASCII `'0'` (0x30). + /// + /// **Confirmed for the sibling opcode, inferred here by shared convention.** The distinction is worth + /// stating precisely, because it is stronger than a guess and weaker than a measurement. + /// + /// *Confirmed, on real hardware:* `0x30` is how this firmware spells "off" for a config key. NOOP has + /// shipped that write since #181 — `setBroadcastHr(false)` writes `0x30` to + /// `whoop_live_hr_in_adv_ind_pkt` through `SET_DEVICE_CONFIG_VALUE` (119 / 0x77) and a Garmin Edge 840 + /// stops seeing the broadcast. `enable_raw_data_w_ecg` independently *reads* `'0'` on an MG whose ECG + /// is not running. Both are the device-config namespace. + /// + /// *Shared convention, which is why it transfers:* the two namespaces are the same wire idiom. Both + /// bodies are the key name as ASCII NUL-padded to 32 bytes followed by a single ASCII-digit value byte + /// at offset 32, both are sent as `[0x01] + body` WITH RESPONSE, and `deviceConfigBody`'s own doc spells + /// the value convention out as "an ASCII digit, '1' = 0x31 / '0' = 0x30". The only differences are the + /// opcode (0x77 vs 0x78) and the body length (33 vs 40 bytes, the feature-flag body carrying 7 trailing + /// zeroes). Nothing about 0x78 makes it one-way: writes through it demonstrably land and demonstrably + /// change stored state — running `enableR22Sequence` moved `enable_sig12` from `'2'` (0x32) to `'1'` + /// (0x31), confirmed by a `GET_FF_VALUE(128)` read before and after. A key that can be written is a key + /// that can be rewritten. + /// + /// *Inferred, and stated plainly:* **`'0'` has never been observed in the feature-flag namespace.** Every + /// `GET_FF_VALUE(128)` read ever taken returned `'1'` or `'2'` — and those sixteen readings are a + /// round-trip of NOOP's own `enableR22Sequence` writes, so they say nothing about what the firmware does + /// with a value it was not handed. The two namespaces are also proven separate at the verb level: a key + /// asked through the wrong verb answers FAILURE. So the device-config evidence above is a strong + /// argument by shared convention, not a measurement of this opcode. + /// + /// *And two pieces of counter-evidence, which is why "0 = false" is not assumed anywhere in this file:* + /// `disable_pip_r26_packets` is written `'2'` despite being named `disable_*` — if these were plain + /// booleans that key would be the one written `'0'`. And `enable_sig12` was corrected `'2'` → `'1'` from + /// a real on-strap capture (#423), i.e. the official app picks *between* `'1'` and `'2'` per flag rather + /// than using one canonical "true". Whatever those two values select, it is probably not a bare boolean, + /// so tri-state `'0'`/`'1'`/`'2'` semantics stay UNESTABLISHED rather than assumed. + /// + /// *Which makes the read-back decisive rather than ceremonial.* A single `GET_FF_VALUE(128)` on the + /// written key separates the three live possibilities, and `R22DisableReport` reports which one happened + /// instead of averaging over them: + /// + /// | Read-back | Meaning | + /// |---|---| + /// | value `'0'` | The write landed and the key now HOLDS `'0'`. Cleared as a value — the expected success. | + /// | `FAILURE(0)` | The key no longer has a stored value at all. Genuinely removed; nothing predicts this. | + /// | value unchanged (`'2'`/`'1'`) | The write was refused or no-oped. `'0'` is not how this namespace says off. | + /// + /// Note that **enumeration cannot substitute for this read.** `'0'` is a stored value, not an absence — + /// six of the seven keys the device-config walk returned read `'0'` and were still enumerated — so a + /// cleared key stays present in a walk rather than dropping out. And the 117/118 feature-flag + /// enumeration carries no value field at all (its record is `[revision][index][validKey][key]`), so it + /// can only ever answer presence. `GET_FF_VALUE(128)` is the only verb that can verify a clear, and it + /// answered cleanly for all sixteen flags on a 5/MG — the older 4.0-derived caution about a stale shared + /// buffer contaminating its value field does not reproduce on this family. + /// + /// The path therefore writes `'0'` to ONE flag first, reads it back, and declines to touch the other + /// fifteen unless the strap actually stopped reporting the old value. One round-trip converts the + /// inference into a measurement on first run. See `R22DisableReport.probeKey` for why that key. + /// + /// What stays unverified even after a clean read-back is narrower than the byte: whether clearing the + /// flags makes the strap's R22 *behaviour* revert. Only wearing the strap and watching the deep records + /// stop answers that, and the report says so rather than claiming it. + public static let featureFlagOffValue: UInt8 = 0x30 + + /// The inverse of `enableR22Sequence`: the same sixteen keys, in the same order, each carrying + /// `featureFlagOffValue`. + /// + /// **Same order as the enable sequence, deliberately.** `enable_r22_packets` — the master flag — is + /// cleared FIRST, so a run interrupted by a disconnect leaves the strap nearer "off" than it started + /// rather than stranded with sub-streams cleared and the master still set. It also keeps the two + /// sequences trivially diffable: same names, same order, only the value byte differs. + /// + /// **On `disable_pip_r26_packets`, whose name inverts.** Fifteen of these 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, and the reasoning is worth being explicit about because it is the one place a reviewer + /// should push back. + /// + /// This sequence is defined as *the undo of the enable sequence* — clear every flag that sequence set — + /// rather than as "set each flag to its semantic opposite". Those are different operations, and only + /// the first one is well defined here. Inverting per key would require knowing what `'1'` and `'2'` + /// each select, and this key is the evidence that we do not: a flag named `disable_*` written `'2'` + /// alongside `enable_*` flags also written `'2'` cannot be reconciled with plain-boolean semantics. + /// + /// So: writing `'0'` to `disable_pip_r26_packets` is expected to stop PIP R26 packets being suppressed + /// — i.e. to 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, and it + /// is called out in the report so nobody is surprised that one key's "off" is another feature's "on". + /// `make_hrfm_visible`, `wear_detect_bias`, `hr_ch_switching`, `ir_hw_switching` and `dorset_inhibit_wpt` + /// are milder versions of the same story: they tune behaviour rather than gate a stream, and clearing + /// them returns that tuning to whatever the firmware does with the flag unset. + /// + /// **What this does NOT do: 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", not "restores the strap to how it shipped". Making the + /// stronger claim true needs a read-before-write snapshot on the ENABLE path; see #174. + public static let disableR22Sequence: [Flag] = enableR22Sequence.map { + Flag($0.name, featureFlagOffValue) + } + + /// Every frame in the disable sequence, sequence-numbered from `firstSeq`. Written exactly like the + /// enable frames — same opcode, same 40-byte body, same spacing, WITH RESPONSE — because they differ + /// only in the value byte. + public static func disableSequenceFrames(firstSeq: UInt8 = 1) -> [[UInt8]] { + disableR22Sequence.enumerated().map { idx, flag in + frame(flag: flag, seq: UInt8((Int(firstSeq) + idx) & 0xFF)) + } + } } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/R22DisableTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/R22DisableTests.swift new file mode 100644 index 0000000000..171e814265 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/R22DisableTests.swift @@ -0,0 +1,320 @@ +import XCTest +@testable import WhoopProtocol + +/// #174: the R22 DISABLE path — the sequence bytes, the key-aware `SET_FF_VALUE(120)` allowlist, and the +/// staged write→read-back run that turns the inferred off value into a measurement. +/// +/// Everything here runs with no app, no strap and no CoreBluetooth: `R22DisableReport` is pure and +/// order-dependent, so the whole verdict table — including the case where the strap refuses `'0'` — is +/// covered without hardware. That matters more than usual here, because the one thing that CANNOT be +/// tested off-strap is whether the firmware accepts the value, and the design is built so that the run +/// itself answers that on first use rather than assuming it. +final class R22DisableTests: XCTestCase { + + private func hex(_ b: [UInt8]) -> String { b.map { String(format: "%02x", $0) }.joined(separator: " ") } + + // MARK: - The sequence + + func testDisableSequenceMirrorsEnableExactly() { + let enable = Whoop5Config.enableR22Sequence + let disable = Whoop5Config.disableR22Sequence + XCTAssertEqual(disable.count, enable.count, "the undo must cover every key the enable sets") + XCTAssertEqual(disable.map(\.name), enable.map(\.name), + "same keys in the same order — the master flag is cleared first") + XCTAssertTrue(disable.allSatisfy { $0.value == Whoop5Config.featureFlagOffValue }) + } + + func testOffValueIsAsciiZero() { + XCTAssertEqual(Whoop5Config.featureFlagOffValue, 0x30) + XCTAssertEqual(Character(UnicodeScalar(Whoop5Config.featureFlagOffValue)), "0") + } + + /// The disable frame differs from the enable frame in EXACTLY one byte — the value at body offset 32 + /// (frame offset 12 + 32 = 44). Anything else changing means the encoding drifted. + func testDisableFrameDiffersFromEnableInOnlyTheValueByte() { + let on = Whoop5Config.frame(flag: Whoop5Config.Flag("enable_r22_packets", 0x32), seq: 1) + let off = Whoop5Config.frame(flag: Whoop5Config.Flag("enable_r22_packets", 0x30), seq: 1) + XCTAssertEqual(on.count, off.count) + let differing = (0.. DeviceConfigReadProbe.ValueResponse { + var record = [UInt8](repeating: 0, count: 32) + for (i, b) in Array(key.utf8).enumerated() where i < 32 { record[i] = b } + record.append(value) + return DeviceConfigReadProbe.ValueResponse(resultCode: resultCode, record: record) + } + + private func failureReply() -> DeviceConfigReadProbe.ValueResponse { + DeviceConfigReadProbe.ValueResponse(resultCode: 0, record: []) + } + + func testPlanStartsWithTheProbeWriteThenItsReadBack() { + var r = R22DisableReport() + let first = r.nextStep() + XCTAssertEqual(first?.stage, .probeWrite) + XCTAssertEqual(first?.key, R22DisableReport.probeKey) + XCTAssertEqual(first?.opcode, 120) + let second = r.nextStep() + XCTAssertEqual(second?.stage, .probeRead) + XCTAssertEqual(second?.key, R22DisableReport.probeKey) + XCTAssertEqual(second?.opcode, 128) + } + + /// The gate: a probe read-back that still shows the old value STOPS the run with fifteen keys untouched. + /// This is the case the staging exists for. + func testProbeRejectionStopsTheRunAndLeavesFifteenKeysUntouched() { + var r = R22DisableReport() + let write = r.nextStep()! + r.noteWriteAck(resultCode: 1, for: write) // SUCCESS ack — deliberately not believed + let read = r.nextStep()! + r.noteReadBack(reply(key: R22DisableReport.probeKey, value: 0x31), for: read) // still '1' + + XCTAssertNil(r.nextStep(), "no further steps once the off value is rejected") + XCTAssertEqual(r.verdict, .offValueRejected) + XCTAssertEqual(r.outcomes[R22DisableReport.probeKey], .unchanged) + let skipped = r.keys.filter { r.outcomes[$0] == .skipped } + XCTAssertEqual(skipped.count, 15, "the other fifteen must be left alone") + XCTAssertTrue(r.render().contains("is NOT how this firmware clears a feature flag")) + XCTAssertEqual(r.writeAcks[R22DisableReport.probeKey], 1, + "the SUCCESS ack is recorded even though the verdict is a rejection") + } + + /// A clean run: probe accepts, the other fifteen are written, all sixteen read back as '0'. + func testFullRunClearsAllSixteen() { + var r = R22DisableReport() + var steps = 0 + while let step = r.nextStep() { + steps += 1 + XCTAssertLessThan(steps, 100, "plan must terminate") + if step.isWrite { + XCTAssertEqual(step.opcode, 120) + r.noteWriteAck(resultCode: 1, for: step) + } else { + XCTAssertEqual(step.opcode, 128) + r.noteReadBack(reply(key: step.key, value: 0x30), for: step) + } + } + XCTAssertEqual(r.verdict, .allCleared) + XCTAssertEqual(r.keys.count, 16) + for key in r.keys { + XCTAssertEqual(r.outcomes[key], .cleared, "\(key) should be cleared") + } + // 1 probe write + 1 probe read + 15 clear writes + 16 verify reads + XCTAssertEqual(steps, 33) + XCTAssertTrue(r.summary.contains("All 16 R22 flags cleared")) + } + + /// FAILURE on a read-back means the key no longer has a stored value at all. Nothing predicted this + /// outcome, so it is reported as its own state rather than folded into "cleared". + func testFailureReadBackIsReportedAsUnsetNotCleared() { + var r = R22DisableReport() + let write = r.nextStep()! + r.noteWriteAck(resultCode: 1, for: write) + let read = r.nextStep()! + r.noteReadBack(failureReply(), for: read) + XCTAssertEqual(r.outcomes[R22DisableReport.probeKey], .unset) + XCTAssertTrue(r.probePassed, "an unset key is a success — the run should continue") + XCTAssertNotNil(r.nextStep(), "the clear stage must follow a passing probe") + } + + func testPartialRunIsReportedAsPartialNotSuccess() { + var r = R22DisableReport() + while let step = r.nextStep() { + if step.isWrite { + r.noteWriteAck(resultCode: 1, for: step) + } else if step.key == "wear_detect_bias" { + r.noteReadBack(reply(key: step.key, value: 0x32), for: step) // refused this one + } else { + r.noteReadBack(reply(key: step.key, value: 0x30), for: step) + } + } + XCTAssertEqual(r.verdict, .partial) + XCTAssertEqual(r.outcomes["wear_detect_bias"], .unchanged) + XCTAssertTrue(r.summary.contains("15 of 16")) + } + + /// A SUCCESS ack whose read-back did not move must never render as success — the #907/#891 rule. + func testSuccessAckWithUnmovedValueIsNeverReportedAsSuccess() { + var r = R22DisableReport() + let write = r.nextStep()! + r.noteWriteAck(resultCode: 1, for: write) + let read = r.nextStep()! + r.noteReadBack(reply(key: R22DisableReport.probeKey, value: 0x31), for: read) + let text = r.render() + XCTAssertFalse(r.verdict == .allCleared) + XCTAssertTrue(text.contains("recorded, not proof")) + XCTAssertTrue(text.contains("NOT treated as proof")) + } + + func testSilentProbeStopsTheRun() { + var r = R22DisableReport() + let write = r.nextStep()! + r.noteTimeout(for: write, seconds: 8) + XCTAssertNil(r.outcomes[R22DisableReport.probeKey], + "a silent WRITE decides nothing — only the read-back does") + let read = r.nextStep()! + r.noteTimeout(for: read, seconds: 8) + XCTAssertEqual(r.verdict, .probeInconclusive) + XCTAssertNil(r.nextStep()) + } + + func testAbandonMarksRemainingKeysSkipped() { + var r = R22DisableReport() + let write = r.nextStep()! + r.noteWriteAck(resultCode: 1, for: write) + r.noteAbandoned("link dropped") + XCTAssertEqual(r.verdict, .abandoned) + XCTAssertNil(r.nextStep()) + XCTAssertEqual(r.keys.filter { r.outcomes[$0] == .skipped }.count, 16) + } + + func testUndecodableReadBackIsNotTreatedAsCleared() { + var r = R22DisableReport() + let write = r.nextStep()! + r.noteWriteAck(resultCode: 1, for: write) + let read = r.nextStep()! + r.noteReadFailure(.crc, for: read) + XCTAssertEqual(r.outcomes[R22DisableReport.probeKey], .undecodable) + XCTAssertFalse(r.probePassed) + XCTAssertEqual(r.verdict, .probeInconclusive) + } + + /// The report must state the four standing caveats verbatim, so the log, the UI and the PR cannot drift + /// apart on what was actually established. + func testRenderCarriesTheCaveats() { + var r = R22DisableReport() + while let step = r.nextStep() { + if step.isWrite { r.noteWriteAck(resultCode: 1, for: step) } + else { r.noteReadBack(reply(key: step.key, value: 0x30), for: step) } + } + let text = r.render() + XCTAssertTrue(text.contains("'0' as the off value is INFERRED")) + XCTAssertTrue(text.contains("not the same as reverted BEHAVIOUR")) + XCTAssertTrue(text.contains("does not restore a snapshot")) + XCTAssertTrue(text.contains("disable_pip_r26_packets inverts")) + // Every key appears in the per-key table with its before value. + for key in r.keys { XCTAssertTrue(text.contains(key), "\(key) missing from the report") } + } + + func testProbeKeyIsTheOneWithAHardwareWriteDemonstration() { + XCTAssertEqual(R22DisableReport.probeKey, "enable_sig12") + XCTAssertTrue(Whoop5Config.enableR22Sequence.contains { $0.name == R22DisableReport.probeKey }) + } +} diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index aaad54f415..2d0d104f5d 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1530,12 +1530,20 @@ public final class BLEManager: NSObject, ObservableObject { || (DeviceConfigReadProbe.isReadOnlyOpcode(command.rawValue) && deviceConfigReport != nil) || command == .sendHistoricalData || command == .historicalDataResult || command == .setClock || command == .getClock - // SET_CONFIG (the R22 deep-stream unlock) is allowed ONLY while the deep-data - // experiment is opted in — it writes a persistent feature flag to the strap, so it - // must never fire on a default install. It's still reversible (just changes which - // data the strap emits) and is what the official app sends. Driven only by - // enableWhoop5DeepData(). (#174) - || (command == .setConfig && PuffinExperiment.deepDataEnabled) + // SET_CONFIG / SET_FF_VALUE (120) — the R22 deep-stream unlock AND its undo. Allowed only + // while the deep-data experiment is opted in, and now additionally only for a KEY and a + // VALUE the gate recognises: one of the sixteen R22 flags, carrying either that key's own + // enable value or the documented off value. The clause this replaces was opcode-only, so + // it admitted ANY feature-flag key with ANY value for as long as the opt-in happened to be + // on — the same weakness #907 closed on opcode 119. Driven only by enableWhoop5DeepData() + // and disableWhoop5DeepData(). (#174) + || FeatureFlagWriteGate.admitsSend(opcode: command.rawValue, + payload: payload, + deepDataOptIn: PuffinExperiment.deepDataEnabled) + // GET_FF_VALUE (128) as the disable run's mandatory READ-BACK. Gated on a disable run being + // in flight, exactly like the read probes' 121/128 clause above, so a default install can + // never form these bytes. The write ack is not trusted; this is what proves the clear. + || (FeatureFlagWriteGate.isReadBackOpcode(command.rawValue) && r22DisableRun != nil) // SET_DEVICE_CONFIG (the Broadcast-HR flag) is allowed ONLY while that opt-in is on — // it writes one persistent device-config value so the strap advertises standard HR. // Reversible; driven only by setBroadcastHr(_:). (#181) @@ -2335,7 +2343,11 @@ public final class BLEManager: NSObject, ObservableObject { // entirely, so counting it as a "deep packet" gave 4.0 owners a bogus deep-data counter (#346). guard selectedModel.deviceFamily == .whoop5 else { return } guard frame.count > 10 else { return } - if frame[8] == 0x24, frame[10] == WhoopCommand.setConfig.rawValue { + // #174: a SET_CONFIG ack means "one enable_r22_* flag accepted" ONLY when we are enabling. A disable + // run writes through the same opcode, so without this guard turning R22 OFF would tick the + // "Strap accepted N/16 R22 flags" counter upwards — the exact opposite of what happened. The + // disable run scores its own acks (and does not trust them; the 128 read-back is its proof). + if frame[8] == 0x24, frame[10] == WhoopCommand.setConfig.rawValue, r22DisableRun == nil { state.r22FlagsAccepted += 1 let total = Whoop5Config.enableR22Sequence.count if state.r22FlagsAccepted == total { @@ -2406,10 +2418,163 @@ public final class BLEManager: NSObject, ObservableObject { } } DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(80 * frames.count + 200)) { [weak self] in - self?.log("Deep-data: sequence sent. Keep the strap on, let it sync, then share your strap log — we're looking for new deep records (type-0x2F) to start arriving. (#174)") + self?.log("Deep-data: sequence sent. Keep the strap on, let it sync, then share your strap log — we're looking for new deep records (type-0x2F) to start arriving. To undo it later, use \"Turn deep data back off\" — disableWhoop5DeepData() writes '0' to the same sixteen keys and reads each one back. (#174)") } } + // MARK: - R22 disable (#174) + + /// Per-step reply window for the disable run. Each step is only sent once the previous reply lands (or + /// times out), so this bounds one round-trip, not the whole run. + private static let r22DisableStepTimeout: TimeInterval = 8 + + /// The in-flight disable run's accumulating report; nil when none is running. Doubles as the send() + /// allowlist's in-flight gate for the 128 read-back. + private var r22DisableRun: R22DisableReport? + /// The step whose reply we are waiting for, nil between steps. + private var r22DisableAwaiting: R22DisableReport.Step? + /// Monotonic step counter so a late timeout from an earlier step cannot cancel a live run. + private var r22DisableStep = 0 + + /// EXPERIMENTAL (#174): the undo of `enableWhoop5DeepData()` — write `'0'` to the sixteen `enable_r22_*` + /// feature flags and report, per key, the value the strap actually stores afterwards. + /// + /// **Why this exists.** The Settings copy has promised since #174 that the R22 unlock is "reversible", + /// and that was true about the hardware and false about the app: NOOP shipped an enable button and no + /// way back, so the only routes out were the official WHOOP app or a factory reset. This closes that. + /// + /// **Why it is staged rather than sixteen writes.** `'0'` is the confirmed off value in the + /// device-config namespace (#181, hardware-validated on the Broadcast-HR flag) and that namespace shares + /// this one's entire wire idiom — but no *feature* flag has ever been observed holding `'0'`, and the two + /// namespaces are proven separate at the verb level. So the run writes ONE flag, reads it back, and only + /// touches the other fifteen if the strap actually stopped reporting the old value. A firmware that + /// refuses `'0'` costs the user one round-trip and yields a publishable answer instead of fifteen mystery + /// writes. See `Whoop5Config.featureFlagOffValue` and `R22DisableReport` for the full reasoning. + /// + /// **The ack is never the proof.** Every write's COMMAND_RESPONSE 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 `GET_FF_VALUE(128)` read returns is reported as state (#907/#891). + /// + /// Same guards as the enable: 5/MG family, explicit opt-in, full encrypted bond. `worn` is deliberately + /// NOT required — the on-wrist gate exists because the R22 STREAM is on-wrist-only, and a user turning + /// the feature off should not have to put the strap back on to do it. Result goes to + /// `LiveState.r22DisableReport` and the strap log; no new storage. Twin of Android + /// `disableWhoop5DeepData()`. + public func disableWhoop5DeepData() { + guard selectedModel.deviceFamily == .whoop5 else { + log("Deep-data disable: needs a WHOOP 5.0/MG strap selected — ignored."); return + } + guard PuffinExperiment.deepDataEnabled else { + // The send allowlist keys off this same pref, so the writes would be dropped anyway. Fail with a + // message that says what to do rather than letting sixteen frames vanish silently. + log("Deep-data disable: the deep-data opt-in is off, so the writes would be refused by the send allowlist. Turn the toggle back on, tap the disable button, then turn it off — ignored."); return + } + guard state.connected, state.encryptedBond else { + log("Deep-data disable: needs the full encrypted bond, not the live-HR-only link. Close the official WHOOP app, put the strap in pairing mode, and bond it to NOOP first — ignored."); return + } + guard r22DisableRun == nil else { + log("Deep-data disable: a disable run is already walking its plan — ignored."); return + } + r22DisableRun = R22DisableReport() + state.r22DisableReport = BLEManager.deviceConfigProbeWaiting + log("Deep-data disable (#174): clearing the \(Whoop5Config.disableR22Sequence.count)-flag R22 sequence — writing '0' via SET_FF_VALUE(120), each verified with GET_FF_VALUE(128). Probing \(R22DisableReport.probeKey) first; the other flags are only written if that one moves.") + advanceR22Disable() + } + + /// Send the next planned step, or finish when the plan is done. + private func advanceR22Disable() { + guard let step = r22DisableRun?.nextStep() else { + finishR22Disable() + return + } + guard let command = WhoopCommand(rawValue: step.opcode) else { + log("Deep-data disable: refusing to send unknown opcode \(step.opcode)") + finishR22Disable() + return + } + let payload: [UInt8] + if step.isWrite { + payload = [0x01] + Whoop5Config.payloadBody(name: step.key, value: Whoop5Config.featureFlagOffValue) + // Fail closed: the same predicate the send path consults, asked here too, so a future edit that + // widened the plan cannot put an unrecognised key or value on the wire. + guard FeatureFlagWriteGate.admitsSend(opcode: step.opcode, payload: payload, + deepDataOptIn: PuffinExperiment.deepDataEnabled) else { + log("Deep-data disable: refusing to write \(step.key) — not admitted by the feature-flag gate") + finishR22Disable() + return + } + } else { + guard FeatureFlagWriteGate.isReadBackOpcode(step.opcode) else { + log("Deep-data disable: refusing to read with opcode \(step.opcode)") + finishR22Disable() + return + } + payload = DeviceConfigReadProbe.requestBody(key: step.key) + } + r22DisableStep &+= 1 + r22DisableAwaiting = step + let armed = r22DisableStep + send(command, payload: payload, writeType: .withResponse) + // BLE callbacks and this timer both run on the main queue, so guard-then-advance is race-free: a + // reply that already landed advanced `r22DisableStep`, and this stale closure no-ops. + DispatchQueue.main.asyncAfter(deadline: .now() + BLEManager.r22DisableStepTimeout) { [weak self] in + guard let self, self.r22DisableRun != nil, self.r22DisableStep == armed, + self.r22DisableAwaiting != nil else { return } + self.r22DisableAwaiting = nil + self.r22DisableRun?.noteTimeout(for: step, seconds: Int(BLEManager.r22DisableStepTimeout)) + self.advanceR22Disable() + } + } + + /// Render + publish + log the report and end the run (which also re-closes the 128 send allowlist). + private func finishR22Disable() { + guard let report = r22DisableRun else { return } + r22DisableRun = nil + r22DisableAwaiting = nil + // The "Strap accepted N/16 R22 flags" line reports the last ENABLE send. If this run cleared even one + // key that claim is stale, so retire it rather than leaving the card asserting the strap is unlocked + // while the report below says it was just cleared. A run that changed nothing leaves it alone. + if report.outcomes.values.contains(where: { $0.isSuccess }) { + state.r22FlagsAccepted = 0 + } + let text = report.render() + log("Deep-data disable (#174):\n\(text)") + state.r22DisableReport = text + } + + /// Clear the #174 disable result (Settings card dismissed). Twin of Android clearR22DisableReport(). + public func clearR22DisableReport() { state.r22DisableReport = nil } + + /// #174: one COMMAND_RESPONSE belonging to a disable run — either a `SET_FF_VALUE(120)` write ack or a + /// `GET_FF_VALUE(128)` read-back. Guarded on a run being IN-FLIGHT so a stray byte match can never + /// surface a result. Parsing — including the CRC gate — lives in the pure `DeviceConfigReadProbe`. + private func handleR22DisableResponse(_ frame: [UInt8], isWhoop5: Bool) { + guard r22DisableRun != nil, let step = r22DisableAwaiting else { return } + r22DisableAwaiting = nil + if step.isWrite { + // The ack is recorded for the transcript and decides nothing. `pay[1]` is the 5/MG result code: + // puffin envelope puts the type @8 and the cmd @10, so the payload starts at 11 and the result + // byte is at 12. + let resultCode: Int? = frame.count > 12 ? Int(frame[12]) : nil + r22DisableRun?.noteWriteAck(resultCode: resultCode, for: step) + } else { + switch DeviceConfigReadProbe.parse(frame: frame, family: isWhoop5 ? .whoop5 : .whoop4, + expecting: step.opcode) { + case .success(let r): r22DisableRun?.noteReadBack(r, for: step) + case .failure(let f): r22DisableRun?.noteReadFailure(f, for: step) + } + } + advanceR22Disable() + } + + /// Abandon a disable run the link interrupted, re-closing the 128 send allowlist. Called from the + /// disconnect path alongside the probe teardowns. + private func abandonR22DisableRun(_ why: String) { + guard r22DisableRun != nil else { return } + r22DisableRun?.noteAbandoned(why) + finishR22Disable() + } + /// EXPERIMENTAL (#181): make a bonded WHOOP 5/MG advertise its heart rate as a standard BLE HR /// sensor (0x180D + the live HR in its manufacturer data) by writing the device-config flag /// `whoop_live_hr_in_adv_ind_pkt` = "1" (on) / "0" (off) via SET_DEVICE_CONFIG (0x77). With it on, a @@ -3844,6 +4009,10 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // …and abandon a plan the link interrupted, re-closing the 121/128 send() allowlist. deviceConfigReport = nil deviceConfigAwaiting = nil + // #174: a disable run interrupted mid-plan is RENDERED rather than dropped — unlike the read-only + // probes above it has already written to the strap, so the user must be told exactly how far it got + // and which keys are still set. This publishes the partial report and re-closes the 128 allowlist. + abandonR22DisableRun("the strap disconnected mid-run") state.clearBiometrics() // and a stale HR / R-R must not outlive the link either state.liveFeedActive = false // a drop while Live is open must not leave a stale "Stop live feed" didBond = false @@ -4756,6 +4925,15 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { DeviceConfigReadProbe.isReadOnlyOpcode(frame[10]) { handleDeviceConfigProbeResponse(frame, isWhoop5: true) } + // #174: a reply belonging to an R22 DISABLE run — either the SET_FF_VALUE(120) write ack + // or the GET_FF_VALUE(128) read-back that verifies it. In-flight-guarded inside, so this + // is a byte compare on every other frame. Note 128 is also matched by the read-probe + // clause above; both handlers guard on their OWN run being live, so exactly one acts. + if frame.count > 10, frame[8] == 0x24, + frame[10] == WhoopCommand.setConfig.rawValue + || frame[10] == WhoopCommand.getFeatureFlagValue.rawValue { + handleR22DisableResponse(frame, isWhoop5: true) + } // #695: a 5/MG GET_DATA_RANGE COMMAND_RESPONSE (puffin envelope: type @8, cmd @10). Feeds // the SAME newest/oldest window + backfill gate + diagnostics as the 4.0 path above — this // reply was previously ignored on 5/MG (the 4.0 handler keyed on frame[6]). cmdOff = 10. diff --git a/Strand/BLE/LiveState.swift b/Strand/BLE/LiveState.swift index 46b3fb2122..68054fd138 100644 --- a/Strand/BLE/LiveState.swift +++ b/Strand/BLE/LiveState.swift @@ -256,6 +256,14 @@ public final class LiveState: ObservableObject { /// sentinel while the walk runs. Nothing is written to the strap to produce it. Cleared on disconnect /// and on dialog dismiss. Twin of the Android WhoopBleClient.deviceConfigProbe flow. @Published public var deviceConfigProbe: String? = nil + + /// #174: the R22 DISABLE report — the per-key result of writing `'0'` to the sixteen feature flags and + /// reading every one of them back with `GET_FF_VALUE`(128), or the waiting sentinel while the run walks. + /// Unlike the two probes above this one DOES write, which is exactly why it reports the value the strap + /// stores rather than the write's own ack. Cleared on disconnect and on dialog dismiss. Twin of the + /// Android WhoopBleClient.r22DisableReport flow. + @Published public var r22DisableReport: String? = nil + /// Wrist-wear state from WRIST_ON/WRIST_OFF events. Defaults true so wear-gated features work /// before the first event arrives; flipped by FrameRouter on a real event. @Published public var worn: Bool = true diff --git a/Strand/BLE/PuffinExperiment.swift b/Strand/BLE/PuffinExperiment.swift index 4cb7c005e2..0e380b91bb 100644 --- a/Strand/BLE/PuffinExperiment.swift +++ b/Strand/BLE/PuffinExperiment.swift @@ -16,8 +16,13 @@ enum PuffinExperiment { /// Separate, more-deliberate opt-in for the WHOOP 5/MG "R22" deep-data unlock — the one probe /// that WRITES a persistent feature flag to the strap (the `enable_r22_*` SET_CONFIG sequence the /// official app sends; documented by judes.club + Asherlc/dofek). Kept distinct from the read-only - /// probes above because it changes strap state, so it must be turned on explicitly and is still - /// fully reversible. Driven only from `BLEManager.enableWhoop5DeepData()`. (#174) + /// probes above because it changes strap state, so it must be turned on explicitly. + /// + /// Reversible, and now actually reversed by code rather than only in the comment: this key gates BOTH + /// `BLEManager.enableWhoop5DeepData()` and `BLEManager.disableWhoop5DeepData()`, and the send allowlist + /// consults `FeatureFlagWriteGate` so only the sixteen R22 keys — carrying either their enable value or + /// the off value — can travel through opcode 120. Note the pref itself writes NOTHING in either + /// direction; it only decides whether a send is permitted. (#174) static let deepDataKey = "noopWhoop5DeepData" static var deepDataEnabled: Bool { UserDefaults.standard.bool(forKey: deepDataKey) } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 92be74a963..cf194d9a49 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -17704,6 +17704,578 @@ } } }, + "Clear the R22 flags on your strap?": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "R22-Flags auf deinem Strap löschen?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Borrar las marcas R22 de tu pulsera?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer les indicateurs R22 sur votre bracelet ?" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancellare i flag R22 sulla fascia?" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Limpar as marcas R22 na sua bracelete?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить флаги R22 на браслете?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "要清除手环上的 R22 标记吗?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "要清除手環上的 R22 標記嗎?" + } + } + } + }, + "Clear flags on strap": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Flags auf dem Strap löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar marcas en la pulsera" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer les indicateurs" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancella i flag sulla fascia" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Limpar marcas na bracelete" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить флаги на браслете" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除手环上的标记" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除手環上的標記" + } + } + } + }, + "Just stop sending": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur nicht mehr senden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solo dejar de enviar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cesser seulement d’envoyer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Smetti solo di inviare" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Apenas parar de enviar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Просто перестать отправлять" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅停止发送" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "僅停止傳送" + } + } + } + }, + "Turning this switch off only stops NOOP sending the unlock. The flags it already wrote stay on the strap until something clears them. NOOP can write the off value to all 16 now and read each one back so you can see what the strap actually stores. Needs the strap connected and bonded.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diesen Schalter auszuschalten stoppt nur, dass NOOP die Freischaltung sendet. Die bereits geschriebenen Flags bleiben auf dem Strap, bis sie etwas löscht. NOOP kann jetzt den Aus-Wert in alle 16 schreiben und jedes zurücklesen, damit du siehst, was der Strap tatsächlich speichert. Der Strap muss verbunden und gekoppelt sein." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Apagar este interruptor solo impide que NOOP envíe el desbloqueo. Las marcas ya escritas siguen en la pulsera hasta que algo las borre. NOOP puede escribir ahora el valor de apagado en las 16 y releer cada una para que veas lo que la pulsera guarda realmente. Requiere la pulsera conectada y vinculada." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactiver cet interrupteur empêche seulement NOOP d’envoyer le déverrouillage. Les indicateurs déjà écrits restent sur le bracelet jusqu’à ce que quelque chose les efface. NOOP peut maintenant écrire la valeur d’arrêt dans les 16 et relire chacun d’eux pour que vous voyiez ce que le bracelet enregistre réellement. Nécessite le bracelet connecté et appairé." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Disattivare questo interruttore impedisce solo a NOOP di inviare lo sblocco. I flag già scritti restano sulla fascia finché qualcosa non li cancella. NOOP può scrivere ora il valore di spegnimento su tutti e 16 e rileggerli uno a uno, così vedi che cosa la fascia memorizza davvero. Richiede la fascia connessa e associata." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Desligar este interruptor apenas impede o NOOP de enviar o desbloqueio. As marcas já escritas permanecem na bracelete até que algo as limpe. O NOOP pode agora escrever o valor de desativação nas 16 e reler cada uma para que veja o que a bracelete guarda realmente. Necessita da bracelete ligada e emparelhada." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выключение этого переключателя лишь останавливает отправку разблокировки из NOOP. Уже записанные флаги остаются на браслете, пока что-нибудь их не очистит. NOOP может сейчас записать значение выключения во все 16 и прочитать каждый обратно, чтобы вы увидели, что браслет действительно хранит. Требуется подключённый и сопряжённый браслет." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭此开关只会让 NOOP 停止发送解锁序列。已写入的标记会留在手环上,直到有东西清除它们。NOOP 现在可以向全部 16 个标记写入关闭值并逐一读回,让你看到手环实际存储的内容。需要手环已连接并完成配对。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉此開關只會讓 NOOP 停止傳送解鎖序列。已寫入的標記會留在手環上,直到有東西清除它們。NOOP 現在可以向全部 16 個標記寫入關閉值並逐一讀回,讓你看到手環實際儲存的內容。需要手環已連線並完成配對。" + } + } + } + }, + "WHOOP 5/MG straps hand a fresh app only live heart rate. The official app switches on the deeper streams (high-rate HR + motion + history) by writing a set of feature flags, a sequence two independent projects have documented. With this on, the button below sends that exact sequence to your strap. Unlike everything else here it does write to the strap — and it is reversible: “Turn deep data back off” writes the off value to the same flags and then reads every one of them back, so you see what the strap actually stores rather than just that it acked. Experimental: it may do nothing on your firmware. iPhone/Android only. A Mac can't write to a 5/MG.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "WHOOP 5/MG-Straps geben einer neuen App nur den Live-Puls. Die offizielle App schaltet die tieferen Datenströme (hochfrequenter Puls + Bewegung + Verlauf) frei, indem sie eine Reihe von Feature-Flags schreibt — eine Sequenz, die zwei unabhängige Projekte dokumentiert haben. Wenn dies aktiv ist, sendet die Schaltfläche unten genau diese Sequenz an deinen Strap. Anders als alles andere hier schreibt sie auf den Strap — und sie ist umkehrbar: „Tiefe Daten wieder ausschalten“ schreibt den Aus-Wert in dieselben Flags und liest anschließend jedes einzeln zurück, sodass du siehst, was der Strap tatsächlich speichert, statt nur, dass er bestätigt hat. Experimentell: Auf deiner Firmware bewirkt es womöglich nichts. Nur iPhone/Android. Ein Mac kann nicht auf einen 5/MG schreiben." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las pulseras WHOOP 5/MG solo entregan la frecuencia cardiaca en vivo a una app nueva. La app oficial activa los flujos más profundos (FC de alta frecuencia + movimiento + historial) escribiendo un conjunto de marcas de función, una secuencia documentada por dos proyectos independientes. Con esto activado, el botón de abajo envía esa secuencia exacta a tu pulsera. A diferencia de todo lo demás aquí, sí escribe en la pulsera, y es reversible: «Volver a desactivar los datos profundos» escribe el valor de apagado en esas mismas marcas y luego relee cada una, de modo que veas lo que la pulsera guarda realmente y no solo que confirmó. Experimental: puede que no haga nada en tu firmware. Solo iPhone/Android. Un Mac no puede escribir en una 5/MG." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les bracelets WHOOP 5/MG ne transmettent à une nouvelle app que la fréquence cardiaque en direct. L’app officielle active les flux plus détaillés (FC haute fréquence + mouvement + historique) en écrivant un ensemble d’indicateurs de fonctionnalité, une séquence documentée par deux projets indépendants. Avec cette option activée, le bouton ci-dessous envoie exactement cette séquence à votre bracelet. Contrairement à tout le reste ici, elle écrit bien sur le bracelet — et elle est réversible : « Désactiver à nouveau les données détaillées » écrit la valeur d’arrêt dans ces mêmes indicateurs puis relit chacun d’eux, pour que vous voyiez ce que le bracelet enregistre réellement et pas seulement qu’il a accusé réception. Expérimental : cela peut ne rien faire sur votre firmware. iPhone/Android uniquement. Un Mac ne peut pas écrire sur un 5/MG." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le fasce WHOOP 5/MG offrono a una nuova app solo la frequenza cardiaca in tempo reale. L’app ufficiale attiva i flussi più profondi (FC ad alta frequenza + movimento + cronologia) scrivendo una serie di flag di funzionalità, una sequenza documentata da due progetti indipendenti. Con questa opzione attiva, il pulsante qui sotto invia esattamente quella sequenza alla tua fascia. A differenza di tutto il resto qui, scrive davvero sulla fascia — ed è reversibile: «Disattiva di nuovo i dati approfonditi» scrive il valore di spegnimento sugli stessi flag e poi li rilegge tutti, così vedi che cosa la fascia memorizza davvero e non solo che ha confermato. Sperimentale: sul tuo firmware potrebbe non fare nulla. Solo iPhone/Android. Un Mac non può scrivere su un 5/MG." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "As braceletes WHOOP 5/MG entregam a uma app nova apenas a frequência cardíaca em direto. A app oficial ativa os fluxos mais profundos (FC de alta frequência + movimento + histórico) escrevendo um conjunto de marcas de funcionalidade, uma sequência documentada por dois projetos independentes. Com isto ativado, o botão abaixo envia essa sequência exata para a sua bracelete. Ao contrário de tudo o resto aqui, escreve mesmo na bracelete — e é reversível: «Voltar a desativar os dados profundos» escreve o valor de desativação nessas mesmas marcas e depois relê cada uma, para que veja o que a bracelete guarda realmente e não apenas que confirmou. Experimental: pode não fazer nada no seu firmware. Apenas iPhone/Android. Um Mac não consegue escrever numa 5/MG." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браслеты WHOOP 5/MG передают новому приложению только пульс в реальном времени. Официальное приложение включает более подробные потоки (частый пульс + движение + история), записывая набор функциональных флагов — последовательность, задокументированную двумя независимыми проектами. При включённой опции кнопка ниже отправляет браслету именно эту последовательность. В отличие от всего остального здесь, она действительно пишет на браслет — и это обратимо: «Снова отключить подробные данные» записывает значение выключения в те же флаги, а затем читает каждый обратно, чтобы вы увидели, что браслет действительно хранит, а не только что он подтвердил. Экспериментально: на вашей прошивке может не дать эффекта. Только iPhone/Android. Mac не может писать на 5/MG." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "WHOOP 5/MG 手环只向新应用提供实时心率。官方应用通过写入一组功能标记来开启更深层的数据流(高频心率 + 运动 + 历史),这一序列已由两个独立项目记录。启用后,下方按钮会向你的手环发送完全相同的序列。与这里其他项不同,它确实会写入手环——而且可以撤销:“重新关闭深层数据”会向同一批标记写入关闭值,然后逐一读回,让你看到手环实际存储的内容,而不仅仅是它已确认。实验性功能:在你的固件上可能毫无作用。仅限 iPhone/Android。Mac 无法写入 5/MG。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "WHOOP 5/MG 手環只向新應用程式提供即時心率。官方應用程式透過寫入一組功能標記來開啟更深層的資料流(高頻心率 + 動作 + 歷史),這一序列已由兩個獨立專案記錄。啟用後,下方按鈕會向你的手環傳送完全相同的序列。與這裡其他項不同,它確實會寫入手環——而且可以復原:「重新關閉深層資料」會向同一批標記寫入關閉值,然後逐一讀回,讓你看到手環實際儲存的內容,而不僅僅是它已確認。實驗性功能:在你的韌體上可能毫無作用。僅限 iPhone/Android。Mac 無法寫入 5/MG。" + } + } + } + }, + "Strap accepted all %lld R22 flags": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Strap hat alle %lld R22-Flags akzeptiert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La pulsera aceptó las %lld marcas R22" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le bracelet a accepté les %lld indicateurs R22" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La fascia ha accettato tutti i %lld flag R22" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A bracelete aceitou as %lld marcas R22" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браслет принял все %lld флагов R22" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手环已接受全部 %lld 个 R22 标记" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手環已接受全部 %lld 個 R22 標記" + } + } + } + }, + "Strap accepted %lld/%lld R22 flags…": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Strap hat %lld/%lld R22-Flags akzeptiert…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La pulsera aceptó %lld/%lld marcas R22…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le bracelet a accepté %lld/%lld indicateurs R22…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La fascia ha accettato %lld/%lld flag R22…" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A bracelete aceitou %lld/%lld marcas R22…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браслет принял %lld/%lld флагов R22…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手环已接受 %lld/%lld 个 R22 标记…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手環已接受 %lld/%lld 個 R22 標記…" + } + } + } + }, + "Clearing R22 flags and reading each one back…": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "R22-Flags werden gelöscht und einzeln zurückgelesen…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrando las marcas R22 y releyendo cada una…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacement des indicateurs R22 et relecture de chacun…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancellazione dei flag R22 e rilettura di ciascuno…" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A limpar as marcas R22 e a reler cada uma…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистка флагов R22 и обратное чтение каждого…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在清除 R22 标记并逐一读回…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在清除 R22 標記並逐一讀回…" + } + } + } + }, + "Turning deep data back off needs an iPhone or Android. A Mac can't form the encrypted bond a 5/MG requires.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Tiefe Daten wieder auszuschalten benötigt ein iPhone oder Android. Ein Mac kann die verschlüsselte Kopplung, die ein 5/MG verlangt, nicht herstellen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Volver a desactivar los datos profundos requiere un iPhone o Android. Un Mac no puede formar el vínculo cifrado que exige una 5/MG." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactiver à nouveau les données détaillées nécessite un iPhone ou un Android. Un Mac ne peut pas former l’appairage chiffré qu’exige un 5/MG." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riattivare lo spegnimento dei dati approfonditi richiede un iPhone o un Android. Un Mac non può creare l’associazione cifrata richiesta da un 5/MG." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Voltar a desativar os dados profundos requer um iPhone ou Android. Um Mac não consegue formar a ligação encriptada que uma 5/MG exige." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чтобы снова отключить подробные данные, нужен iPhone или Android. Mac не может установить шифрованное сопряжение, которого требует 5/MG." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新关闭深层数据需要 iPhone 或 Android。Mac 无法建立 5/MG 所需的加密配对。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新關閉深層資料需要 iPhone 或 Android。Mac 無法建立 5/MG 所需的加密配對。" + } + } + } + }, + "Needs the full encrypted bond: close the official WHOOP app and pair the strap to NOOP first (a live-HR-only link can't carry the write).": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Benötigt die vollständig verschlüsselte Kopplung: Schließe zuerst die offizielle WHOOP-App und koppele den Strap mit NOOP (eine Verbindung nur für den Live-Puls kann den Schreibvorgang nicht übertragen)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Necesita el vínculo cifrado completo: cierra primero la app oficial de WHOOP y empareja la pulsera con NOOP (un enlace solo de FC en vivo no puede transportar la escritura)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nécessite l’appairage chiffré complet : fermez d’abord l’app WHOOP officielle et appairez le bracelet à NOOP (une liaison limitée à la FC en direct ne peut pas transporter l’écriture)." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Richiede l’associazione cifrata completa: chiudi prima l’app WHOOP ufficiale e associa la fascia a NOOP (un collegamento solo per la FC in tempo reale non può trasportare la scrittura)." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Necessita da ligação encriptada completa: feche primeiro a app oficial da WHOOP e emparelhe a bracelete com o NOOP (uma ligação apenas de FC em direto não consegue transportar a escrita)." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуется полное шифрованное сопряжение: сначала закройте официальное приложение WHOOP и сопрягите браслет с NOOP (канал только для пульса в реальном времени не может передать запись)." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要完整的加密配对:请先关闭官方 WHOOP 应用并将手环与 NOOP 配对(仅传输实时心率的连接无法承载写入)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要完整的加密配對:請先關閉官方 WHOOP 應用程式並將手環與 NOOP 配對(僅傳輸即時心率的連線無法承載寫入)。" + } + } + } + }, + "Writes the off value to all 16 flags and reads each one back, so you see what the strap stores rather than just that it acked. The off value is inferred from the flag NOOP already turns off this way for Garmin broadcast — it has never been seen on an R22 flag, so NOOP tries one flag first and stops if the strap refuses it. Clearing the flags is not the same as watching the deep records stop: check that by syncing afterwards.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schreibt den Aus-Wert in alle 16 Flags und liest jedes einzeln zurück, sodass du siehst, was der Strap tatsächlich speichert — nicht nur, dass er bestätigt hat. Der Aus-Wert ist von dem Flag abgeleitet, das NOOP für die Garmin-Übertragung bereits so ausschaltet; auf einem R22-Flag wurde er noch nie beobachtet. Deshalb testet NOOP zuerst ein einzelnes Flag und bricht ab, wenn der Strap es ablehnt. Gelöschte Flags sind nicht dasselbe wie tatsächlich versiegende Tiefendaten: prüfe das mit einer anschließenden Synchronisierung." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escribe el valor de apagado en las 16 marcas y vuelve a leer cada una, para que veas lo que la pulsera guarda en vez de solo que confirmó. El valor de apagado se deduce de la marca que NOOP ya desactiva así para la difusión Garmin; nunca se ha visto en una marca R22, por eso NOOP prueba primero con una sola y se detiene si la pulsera la rechaza. Borrar las marcas no es lo mismo que ver parar los registros profundos: compruébalo sincronizando después." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écrit la valeur d’arrêt dans les 16 indicateurs et relit chacun d’eux, pour que vous voyiez ce que le bracelet enregistre réellement et pas seulement qu’il a accusé réception. La valeur d’arrêt est déduite de l’indicateur que NOOP désactive déjà ainsi pour la diffusion Garmin ; elle n’a jamais été observée sur un indicateur R22, c’est pourquoi NOOP en teste un seul d’abord et s’arrête si le bracelet le refuse. Effacer les indicateurs n’équivaut pas à voir cesser les enregistrements détaillés : vérifiez-le en synchronisant ensuite." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scrive il valore di spegnimento su tutti e 16 i flag e li rilegge uno a uno, così vedi che cosa la fascia memorizza e non solo che ha confermato. Il valore di spegnimento è dedotto dal flag che NOOP già disattiva così per la trasmissione Garmin: non è mai stato osservato su un flag R22, perciò NOOP ne prova prima uno solo e si ferma se la fascia lo rifiuta. Cancellare i flag non equivale a vedere fermarsi le registrazioni approfondite: verificalo sincronizzando dopo." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Escreve o valor de desativação nas 16 marcas e lê cada uma de volta, para que veja o que a pulseira guarda e não apenas que confirmou. O valor de desativação é inferido da marca que o NOOP já desliga assim para a transmissão Garmin — nunca foi visto numa marca R22, por isso o NOOP testa primeiro uma só e pára se a pulseira a recusar. Limpar as marcas não é o mesmo que ver os registos profundos pararem: confirme isso sincronizando depois." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Записывает значение выключения во все 16 флагов и читает каждый обратно, чтобы вы видели, что браслет хранит, а не только что он подтвердил. Значение выключения выведено из флага, который NOOP уже так отключает для трансляции на Garmin: на флаге R22 оно никогда не наблюдалось, поэтому NOOP сначала пробует один флаг и останавливается, если браслет его отвергнет. Очистка флагов — не то же самое, что увидеть остановку подробных записей: проверьте это последующей синхронизацией." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "向全部 16 个标记写入关闭值,并逐一读回,让你看到手环实际存储的内容,而不仅仅是它已确认。该关闭值是从 NOOP 已用同样方式关闭的 Garmin 广播标记推断出来的——在 R22 标记上从未被观察到,因此 NOOP 会先试一个标记,若手环拒绝就停止。清除标记与看到深层记录停止并不相同:之后同步一次来确认。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "向全部 16 個標記寫入關閉值,並逐一讀回,讓你看到手環實際儲存的內容,而不僅僅是它已確認。該關閉值是從 NOOP 已用同樣方式關閉的 Garmin 廣播標記推斷出來的——在 R22 標記上從未被觀察到,因此 NOOP 會先試一個標記,若手環拒絕就停止。清除標記與看到深層記錄停止並不相同:之後同步一次來確認。" + } + } + } + }, "A note on blood oxygen and your model": { "localizations": { "de": { diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index dc7a06651a..4c1a96db96 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -10,6 +10,9 @@ import PhotosUI import StrandDesign import StrandAnalytics import WhoopStore +// #174: the R22 card reads the flag COUNT off `Whoop5Config.enableR22Sequence` rather than restating it — +// the hardcoded "15" outlived the sequence growing to 16 and declared success a flag early. +import WhoopProtocol /// Settings — profile (powers zones / calories / recovery), strap connection, and about. /// Grouped cards on surface.raised with a two-column form feel. @@ -37,6 +40,13 @@ struct SettingsView: View { /// persistent feature flag to the strap. See [PuffinExperiment.deepDataKey]. (#174) @AppStorage(PuffinExperiment.deepDataKey) private var deepDataEnabled = false + /// #174: set when the deep-data switch is turned OFF, so the app can OFFER to clear the flags on the + /// strap instead of silently leaving them set. The switch alone has never written anything in either + /// direction — it gates sends — so turning it off used to change nothing on the hardware while reading + /// like an undo. Asking is the right shape rather than writing automatically: the strap may not be + /// connected, and a write to bonded hardware is not something a toggle should do unannounced. + @State private var confirmingDeepDataDisable = false + /// Opt-in "Broadcast heart rate" (off by default) — makes the strap advertise its HR as a standard /// BLE sensor for Garmin/Zwift/gym kit. See [PuffinExperiment.broadcastHrKey]. (#181) @AppStorage(PuffinExperiment.broadcastHrKey) private var broadcastHrEnabled = false @@ -240,6 +250,16 @@ struct SettingsView: View { } message: { Text("This restarts the roughly 4-night build-up for Charge and your HRV baseline. Your history stays. Use it if a bad first week, like wearing it while sick, set your baseline off.") } + // #174: the switch going OFF is the moment to offer the undo. Declining leaves the flags set and + // says so — which is still an improvement on the old behaviour, where the same tap silently left + // them set with no indication either way. + .confirmationDialog("Clear the R22 flags on your strap?", + isPresented: $confirmingDeepDataDisable, titleVisibility: .visible) { + Button("Clear flags on strap") { model.ble.disableWhoop5DeepData() } + Button("Just stop sending", role: .cancel) { } + } message: { + Text("Turning this switch off only stops NOOP sending the unlock. The flags it already wrote stay on the strap until something clears them. NOOP can write the off value to all 16 now and read each one back so you can see what the strap actually stores. Needs the strap connected and bonded.") + } .confirmationDialog("Mark optical experiment phase", isPresented: $showOpticalPhasePicker, titleVisibility: .visible) { ForEach(PuffinOpticalExperimentPhase.allCases, id: \.self) { phase in @@ -1405,6 +1425,35 @@ struct SettingsView: View { #endif } + /// How many flags the enable sequence actually writes. Read from the sequence rather than restated, so + /// the card cannot drift from it again — it said "15" for the whole life of the 16-flag sequence (#174). + private var r22FlagCount: Int { Whoop5Config.enableR22Sequence.count } + + /// The disable button is gated like the enable one MINUS the wear check: the on-wrist requirement exists + /// because the R22 *stream* is on-wrist gated, and nothing about clearing a stored flag needs the strap + /// worn. Making a user strap it back on to turn a feature off would be the wrong trade. + private var deepDataDisableButtonDisabled: Bool { + #if os(macOS) + return true + #else + return !live.encryptedBond || live.r22DisableReport == BLEManager.deviceConfigProbeWaiting + #endif + } + + /// The reason line under the disable button. Says what the run will do and, crucially, what it can and + /// cannot establish — the off value is inferred from the sibling namespace, so the read-back is the + /// evidence, and a cleared flag is still not the same as observed behaviour reverting. + private var deepDataDisableButtonReason: String { + #if os(macOS) + return String(localized: "Turning deep data back off needs an iPhone or Android. A Mac can't form the encrypted bond a 5/MG requires.") + #else + if !live.encryptedBond { + return String(localized: "Needs the full encrypted bond: close the official WHOOP app and pair the strap to NOOP first (a live-HR-only link can't carry the write).") + } + return String(localized: "Writes the off value to all 16 flags and reads each one back, so you see what the strap stores rather than just that it acked. The off value is inferred from the flag NOOP already turns off this way for Garmin broadcast — it has never been seen on an R22 flag, so NOOP tries one flag first and stops if the strap refuses it. Clearing the flags is not the same as watching the deep records stop: check that by syncing afterwards.") + #endif + } + private var fiveMGCard: some View { SettingsSection( icon: "flask.fill", @@ -1434,7 +1483,11 @@ struct SettingsView: View { } .toggleStyle(.switch) .tint(StrandPalette.accent) - Text("WHOOP 5/MG straps hand a fresh app only live heart rate. The official app switches on the deeper streams (high-rate HR + motion + history) by writing a set of feature flags, a sequence two independent projects have documented. With this on, the button below sends that exact sequence to your strap. Unlike everything else here it does write to the strap, but it's reversible (it only changes which data the strap chooses to emit) and is the same thing the official app does. Experimental: it may do nothing on your firmware. iPhone/Android only. A Mac can't write to a 5/MG.") + // #174: turning the switch OFF used to write nothing — it only hid the enable button, so the + // strap kept every flag the enable sequence set while the UI implied it had been undone. + // Now it offers the real undo. Turning it ON still writes nothing until the button is tapped. + .onChangeCompat(of: deepDataEnabled) { on in if !on { confirmingDeepDataDisable = true } } + Text("WHOOP 5/MG straps hand a fresh app only live heart rate. The official app switches on the deeper streams (high-rate HR + motion + history) by writing a set of feature flags, a sequence two independent projects have documented. With this on, the button below sends that exact sequence to your strap. Unlike everything else here it does write to the strap — and it is reversible: \u{201C}Turn deep data back off\u{201D} writes the off value to the same flags and then reads every one of them back, so you see what the strap actually stores rather than just that it acked. Experimental: it may do nothing on your firmware. iPhone/Android only. A Mac can't write to a 5/MG.") .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) @@ -1448,14 +1501,28 @@ struct SettingsView: View { .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) + // #174: the undo. Offered whenever the flags may be set — which is any time the opt-in + // has been on, not only right after a send, because the flags persist across launches + // and the app has no record of what a previous install wrote. + NoopButton("Turn deep data back off", systemImage: "bolt.slash", kind: .secondary) { + model.ble.disableWhoop5DeepData() + } + .disabled(deepDataDisableButtonDisabled) + Text(deepDataDisableButtonReason) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + // Live R22 telemetry (#174): proof of what the strap is doing right now. + // The threshold and the number are both driven off the sequence itself — they were + // hardcoded to 15 while the sequence carried 16, so the card declared success one flag + // early and named a count that had already drifted. if live.r22FlagsAccepted > 0 { - Label(live.r22FlagsAccepted >= 15 - ? "Strap accepted all 15 R22 flags" - : "Strap accepted \(live.r22FlagsAccepted)/15 R22 flags…", - systemImage: live.r22FlagsAccepted >= 15 ? "checkmark.seal.fill" : "ellipsis") + Label(live.r22FlagsAccepted >= r22FlagCount + ? "Strap accepted all \(r22FlagCount) R22 flags" + : "Strap accepted \(live.r22FlagsAccepted)/\(r22FlagCount) R22 flags…", + systemImage: live.r22FlagsAccepted >= r22FlagCount ? "checkmark.seal.fill" : "ellipsis") .font(StrandFont.caption) - .foregroundStyle(live.r22FlagsAccepted >= 15 ? StrandPalette.statusPositive : StrandPalette.textSecondary) + .foregroundStyle(live.r22FlagsAccepted >= r22FlagCount ? StrandPalette.statusPositive : StrandPalette.textSecondary) } if live.deepPacketsThisSession > 0 { Label(live.deepPacketsThisSession == 1 @@ -1464,11 +1531,31 @@ struct SettingsView: View { systemImage: "clock.arrow.circlepath") .font(StrandFont.caption) .foregroundStyle(StrandPalette.textSecondary) - } else if live.r22FlagsAccepted >= 15 { + } else if live.r22FlagsAccepted >= r22FlagCount { Text("Flags accepted, but the enable sequence doesn't start a separate live stream. The deep records arrive as part of the normal history sync (#494).") .font(StrandFont.caption) .foregroundStyle(StrandPalette.textTertiary) } + + // #174: the disable run's per-key result. Shown verbatim because the interesting part is + // the read-back table, not a green tick — a write that acked SUCCESS but did not move + // the stored value renders here as "unchanged", which is the case worth seeing. + if let report = live.r22DisableReport { + if report == BLEManager.deviceConfigProbeWaiting { + Label("Clearing R22 flags and reading each one back\u{2026}", systemImage: "ellipsis") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } else { + Text(report) + .font(StrandFont.caption.monospaced()) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + NoopButton("Dismiss disable report", systemImage: "xmark", kind: .secondary) { + model.ble.clearR22DisableReport() + } + } + } } Divider().overlay(StrandPalette.hairline) diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index be009f1544..b18f889ade 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -37,6 +37,8 @@ import com.noop.protocol.BackfillCaptureJsonl import com.noop.protocol.BackfillCaptureRecord import com.noop.protocol.BackfillCaptureSummary import com.noop.protocol.CommandNumber +import com.noop.protocol.FeatureFlagWriteGate +import com.noop.protocol.R22DisableReport import com.noop.protocol.DeviceFamily import com.noop.protocol.DeviceConfigReadProbe import com.noop.protocol.DeviceConfigReadProbeReport @@ -1548,6 +1550,21 @@ class WhoopBleClient( /** Monotonic step counter so a late timeout from an earlier step can't cancel a live walk. */ private var deviceConfigStep = 0 + // #174: the R22 DISABLE report — the per-key result of writing '0' to the sixteen feature flags and + // reading every one back with GET_FF_VALUE(128), or the waiting sentinel while the run walks. Unlike + // the two probes above this one DOES write, which is exactly why it reports the value the strap stores + // rather than the write's own ack. + private val _r22DisableReport = MutableStateFlow(null) + val r22DisableReport: StateFlow = _r22DisableReport.asStateFlow() + + /** The in-flight #174 disable run; null when none is running. Doubles as the [send] allowlist's + * in-flight gate for the 128 read-back. */ + private var r22DisableRun: R22DisableReport? = null + /** The step whose reply we are waiting for, null between steps. */ + private var r22DisableAwaiting: R22DisableReport.Step? = null + /** Monotonic step counter so a late timeout from an earlier step can't cancel a live run. */ + private var r22DisableStep = 0 + private val _connectedPeripheralAddress = MutableStateFlow(null) /** The BLE address of the strap currently connected, or null when disconnected. Twin of macOS * BLEManager.connectedPeripheralUUID — drives SourceCoordinator's first-connect identity adoption. */ @@ -2861,10 +2878,20 @@ class WhoopBleClient( // keep their own separate opt-in clauses below and are never sent from this path. Driven // only by probeDeviceConfigValues() (user-initiated, Test Centre gated). !(DeviceConfigReadProbe.isReadOnlyOpcode(cmd.rawValue) && deviceConfigReport != null) && - // SET_CONFIG (the R22 deep-stream unlock) is allowed ONLY while the deep-data experiment - // is opted in — it writes a persistent feature flag to the strap, so it must never fire - // on a default install. Reversible; driven only by enableWhoop5DeepData(). (#174) - !(cmd == CommandNumber.SET_CONFIG && puffinExperiment.isDeepDataEnabled) && + // SET_CONFIG / SET_FF_VALUE (120) — the R22 deep-stream unlock AND its undo. Allowed only + // while the deep-data experiment is opted in, and now additionally only for a KEY and a + // VALUE the gate recognises: one of the sixteen R22 flags carrying either that key's own + // enable value or the documented off value. The clause this replaces was opcode-only, so + // it admitted ANY feature-flag key with ANY value for as long as the opt-in happened to be + // on — the same weakness #907 closed on opcode 119. Driven only by enableWhoop5DeepData() + // and disableWhoop5DeepData(). (#174) + !FeatureFlagWriteGate.admitsSend( + cmd.rawValue, payload, puffinExperiment.isDeepDataEnabled, + ) && + // GET_FF_VALUE (128) as the disable run's mandatory READ-BACK. Gated on a disable run being + // in flight, exactly like the read probes' 121/128 clause above, so a default install can + // never form these bytes. The write ack is not trusted; this is what proves the clear. + !(FeatureFlagWriteGate.isReadBackOpcode(cmd.rawValue) && r22DisableRun != null) && // SET_DEVICE_CONFIG (the Broadcast-HR flag) is allowed ONLY while that opt-in is on. // Reversible; driven only by setBroadcastHr(). (#181) !(cmd == CommandNumber.SET_DEVICE_CONFIG && puffinExperiment.broadcastHr)) { @@ -4813,6 +4840,16 @@ class WhoopBleClient( ) { handleDeviceConfigProbeResponse(frame) } + // #174: a reply belonging to an R22 DISABLE run — either the SET_FF_VALUE(120) write + // ack or the GET_FF_VALUE(128) read-back that verifies it. In-flight-guarded inside. + // 128 is also matched by the read-probe clause above; both handlers guard on their OWN + // run being live, so exactly one acts. + if (frame.size > cmdOff && (frame[cmdOff - 2].toInt() and 0xFF) == 0x24 && + ((frame[cmdOff].toInt() and 0xFF) == CommandNumber.SET_CONFIG.rawValue || + (frame[cmdOff].toInt() and 0xFF) == CommandNumber.GET_FF_VALUE.rawValue) + ) { + handleR22DisableResponse(frame) + } if (frame.size > cmdOff && (frame[cmdOff].toInt() and 0xFF) == CommandNumber.GET_DATA_RANGE.rawValue) { // #451: dump raw GET_DATA_RANGE response bytes unconditionally (even if decode returns // null) so a stale/wrong-epoch "newest" can be told apart from a frame-alignment bug in @@ -4953,7 +4990,12 @@ class WhoopBleClient( if (connectedFamily != DeviceFamily.WHOOP5) return if (frame.size <= 10) return val type = frame[8].toInt() and 0xFF - if (type == 0x24 && (frame[10].toInt() and 0xFF) == CommandNumber.SET_CONFIG.rawValue) { + // #174: a SET_CONFIG ack means "one enable_r22_* flag accepted" ONLY when we are enabling. A + // disable run writes through the same opcode, so without this guard turning R22 OFF would tick the + // "Strap accepted N/16 R22 flags" counter upwards — the exact opposite of what happened. The + // disable run scores its own acks (and does not trust them; the 128 read-back is its proof). + if (type == 0x24 && (frame[10].toInt() and 0xFF) == CommandNumber.SET_CONFIG.rawValue && + r22DisableRun == null) { val n = _state.value.r22FlagsAccepted + 1 _state.update { it.copy(r22FlagsAccepted = n) } val total = Whoop5Config.enableR22Sequence.size @@ -5656,10 +5698,145 @@ class WhoopBleClient( }, 80L * i) } handler.postDelayed({ - log("Deep-data: sequence sent. Keep the strap on, let it sync, then share your strap log — we're looking for new deep records (type-0x2F) to start arriving. (#174)") + log("Deep-data: sequence sent. Keep the strap on, let it sync, then share your strap log — we're looking for new deep records (type-0x2F) to start arriving. To undo it later, use \"Turn deep data back off\" — disableWhoop5DeepData() writes '0' to the same sixteen keys and reads each one back. (#174)") }, 80L * flags.size + 200L) } + /** + * EXPERIMENTAL (#174): the undo of [enableWhoop5DeepData] — write '0' to the sixteen `enable_r22_*` + * feature flags and report, per key, the value the strap actually stores afterwards. Port of + * `BLEManager.disableWhoop5DeepData`. + * + * Why this exists: the Settings copy has promised since #174 that the R22 unlock is "reversible", and + * that was true about the hardware and false about the app — NOOP shipped an enable button and no way + * back, so the only routes out were the official WHOOP app or a factory reset. + * + * Why it is staged rather than sixteen writes: '0' is the confirmed off value in the device-config + * namespace (#181, hardware-validated) and that namespace shares this one's entire wire idiom — but no + * FEATURE flag has ever been observed holding '0', and the two namespaces are proven separate at the + * verb level. So the run writes ONE flag, reads it back, and only touches the other fifteen if the + * strap actually stopped reporting the old value. + * + * The ack is never the proof: SELECT_WRIST returns SUCCESS for a no-op and FAILURE for a real mutation + * on this firmware, so only the value a GET_FF_VALUE(128) read returns is reported as state (#907/#891). + * + * Same guards as the enable minus wear: 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. + */ + fun disableWhoop5DeepData() { + if (connectedFamily != DeviceFamily.WHOOP5) { + log("Deep-data disable: needs a WHOOP 5.0/MG strap — ignored."); return + } + if (!puffinExperiment.isDeepDataEnabled) { + // The send allowlist keys off this same pref, so the writes would be dropped anyway. Fail with + // a message that says what to do rather than letting sixteen frames vanish silently. + log("Deep-data disable: the deep-data opt-in is off, so the writes would be refused by the send allowlist. Turn the toggle back on, tap the disable button, then turn it off — ignored."); return + } + val s = _state.value + if (!s.connected || !s.encryptedBond) { + log("Deep-data disable: needs the full encrypted bond, not the live-HR-only link. Close the official WHOOP app, put the strap in pairing mode, and bond it to NOOP first — ignored."); return + } + if (r22DisableRun != null) { + log("Deep-data disable: a disable run is already walking its plan — ignored."); return + } + r22DisableRun = R22DisableReport() + _r22DisableReport.value = WAITING_DEVICE_CONFIG_PROBE + log( + "Deep-data disable (#174): clearing the ${Whoop5Config.disableR22Sequence.size}-flag R22 " + + "sequence — writing '0' via SET_FF_VALUE(120), each verified with GET_FF_VALUE(128). " + + "Probing ${R22DisableReport.PROBE_KEY} first; the other flags are only written if that one moves." + ) + advanceR22Disable() + } + + /** Send the next planned step, or finish when the plan is done. */ + private fun advanceR22Disable() { + val step = r22DisableRun?.nextStep() + if (step == null) { finishR22Disable(); return } + val cmd = CommandNumber.entries.firstOrNull { it.rawValue == step.opcode } + if (cmd == null) { + log("Deep-data disable: refusing to send unknown opcode ${step.opcode}") + finishR22Disable(); return + } + val payload: ByteArray + if (step.isWrite) { + payload = byteArrayOf(0x01) + + Whoop5Config.payloadBody(step.key, Whoop5Config.FEATURE_FLAG_OFF_VALUE) + // Fail closed: the same predicate the send path consults, asked here too, so a future edit that + // widened the plan cannot put an unrecognised key or value on the wire. + if (!FeatureFlagWriteGate.admitsSend(step.opcode, payload, puffinExperiment.isDeepDataEnabled)) { + log("Deep-data disable: refusing to write ${step.key} — not admitted by the feature-flag gate") + finishR22Disable(); return + } + } else { + if (!FeatureFlagWriteGate.isReadBackOpcode(step.opcode)) { + log("Deep-data disable: refusing to read with opcode ${step.opcode}") + finishR22Disable(); return + } + payload = DeviceConfigReadProbe.requestBody(step.key) + } + r22DisableStep++ + r22DisableAwaiting = step + val armed = r22DisableStep + send(cmd, payload, withResponse = true) + handler.postDelayed({ + if (r22DisableRun != null && r22DisableStep == armed && r22DisableAwaiting != null) { + r22DisableAwaiting = null + r22DisableRun?.noteTimeout(step, (DEVICE_CONFIG_PROBE_TIMEOUT_MS / 1000).toInt()) + advanceR22Disable() + } + }, DEVICE_CONFIG_PROBE_TIMEOUT_MS) + } + + /** Render + publish + log the report and end the run (which also re-closes the 128 send allowlist). */ + private fun finishR22Disable() { + val report = r22DisableRun ?: return + r22DisableRun = null + r22DisableAwaiting = null + // The "Strap accepted N/16 R22 flags" line reports the last ENABLE send. If this run cleared even + // one key that claim is stale, so retire it rather than leaving the card asserting the strap is + // unlocked while the report below says it was just cleared. + if (report.outcomes.values.any { it.isSuccess }) { + _state.update { it.copy(r22FlagsAccepted = 0) } + } + val text = report.render() + log("Deep-data disable (#174):\n$text") + _r22DisableReport.value = text + } + + /** Clear the #174 disable result (Settings card dismissed). */ + fun clearR22DisableReport() { _r22DisableReport.value = null } + + /** + * #174: one COMMAND_RESPONSE belonging to a disable run — either a SET_FF_VALUE(120) write ack or a + * GET_FF_VALUE(128) read-back. Guarded on a run being IN-FLIGHT so a stray byte match can never + * surface a result. Parsing — including the CRC gate — lives in the pure [DeviceConfigReadProbe]. + */ + private fun handleR22DisableResponse(frame: ByteArray) { + if (r22DisableRun == null) return + val step = r22DisableAwaiting ?: return + r22DisableAwaiting = null + if (step.isWrite) { + // The ack is recorded for the transcript and decides nothing. The puffin envelope puts the type + // at 8 and the cmd at 10, so the payload starts at 11 and the result byte is at 12. + val resultCode = if (frame.size > 12) frame[12].toInt() and 0xFF else null + r22DisableRun?.noteWriteAck(resultCode, step) + } else { + val parsed = DeviceConfigReadProbe.parse(frame, connectedFamily, step.opcode) + val value = parsed.value + if (value != null) r22DisableRun?.noteReadBack(value, step) + else r22DisableRun?.noteReadFailure(parsed.failure!!, step) + } + advanceR22Disable() + } + + /** Abandon a disable run the link interrupted, re-closing the 128 send allowlist. */ + private fun abandonR22DisableRun(why: String) { + if (r22DisableRun == null) return + r22DisableRun?.noteAbandoned(why) + finishR22Disable() + } + /** * SET_CLOCK(10) payload = the strap's 8-byte form: [seconds u32 LE][subseconds u32 LE]. * Port of `BLEManager.setClockPayload`. The payload LENGTH is firmware-specific: newer WHOOP 4 @@ -6825,6 +7002,10 @@ class WhoopBleClient( // #103: same for the device-config READ probe — dropping the report re-closes the 121/128 // send() allowlist. _deviceConfigProbe.value = null + // #174: a disable run interrupted mid-plan is RENDERED rather than dropped — unlike the read-only + // probes it has already written to the strap, so the user must be told how far it got and which + // keys are still set. This publishes the partial report and re-closes the 128 allowlist. + abandonR22DisableRun("the strap disconnected mid-run") deviceConfigReport = null deviceConfigAwaiting = null reset() diff --git a/android/app/src/main/java/com/noop/protocol/R22Disable.kt b/android/app/src/main/java/com/noop/protocol/R22Disable.kt new file mode 100644 index 0000000000..75757afd9e --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/R22Disable.kt @@ -0,0 +1,393 @@ +package com.noop.protocol + +/** + * #174: the key-aware allowlist for SET_FF_VALUE (120 / 0x78) — the feature-flag WRITE verb the R22 + * enable and disable sequences share. Direct twin of the Swift `FeatureFlagWriteGate` + * (Packages/WhoopProtocol/Sources/WhoopProtocol/R22Disable.swift) — keep them byte-identical. + * + * Before this file the 5/MG send path admitted opcode 120 on an opcode-only clause (the deep-data opt-in + * alone), which admits ANY feature-flag key with ANY value for as long as that opt-in happens to be on. + * The sixteen R22 keys are the only ones NOOP has business writing, and the disable path doubles the + * number of values travelling through that opcode, so the clause is tightened rather than widened. Same + * discipline as `DeviceConfigReadProbe.isReadOnlyOpcode`: one pure predicate the send path itself + * consults, so a unit test proving the predicate rejects something proves it about the real wire path. + * + * Pure: no Bluetooth, no I/O, no preferences. The app layer supplies the opt-in boolean. + */ +object FeatureFlagWriteGate { + + /** SET_FF_VALUE (120 / 0x78) — the feature-flag write verb. The only opcode this gate admits. */ + const val SET_FEATURE_FLAG_VALUE_CMD = 120 + + /** SET_DEVICE_CONFIG_VALUE (119 / 0x77) — the OTHER namespace's write verb. Named here for exactly + * one reason: so [admitsSend] can be proved to refuse it. */ + const val SET_DEVICE_CONFIG_VALUE_CMD = 119 + + /** GET_FF_VALUE (128 / 0x80) — the read verb the mandatory post-write read-back uses. */ + const val GET_FEATURE_FLAG_VALUE_CMD = 128 + + /** Width of the key-name field in a feature-flag body (`Whoop5Config.payloadBody` NUL-pads to this). */ + const val NAME_FIELD_BYTES = 32 + + /** Total length of the feature-flag body: 32-byte name, value byte, 7 zero bytes. */ + const val BODY_BYTES = 40 + + /** The sixteen keys, taken from the enable sequence rather than restated — one list, one truth. */ + val r22Keys: List get() = Whoop5Config.enableR22Sequence.map { it.name } + + /** The value the enable sequence writes for [key], or null when [key] is not an R22 flag. */ + fun enableValue(key: String): Int? = + Whoop5Config.enableR22Sequence.firstOrNull { it.name == key }?.value + + /** Whether [value] is one NOOP may write for [key]: that key's own enable value, or the off value. */ + fun isWritableValue(value: Int, key: String): Boolean { + val on = enableValue(key) ?: return false + return value == on || value == Whoop5Config.FEATURE_FLAG_OFF_VALUE + } + + /** One parsed SET_FF_VALUE body. */ + data class KeyValue(val key: String, val value: Int) + + /** + * The key name and value carried by a SET_FF_VALUE payload, or null when the payload is not shaped + * like one. The payload the send path holds is `[0x01] + payloadBody(...)`. A name is only returned + * when it is printable ASCII and the remainder of the field is genuine NUL padding, so a short, + * mis-shaped or binary-carrying body yields null and is refused rather than guessed at. + */ + fun keyAndValue(sendPayload: ByteArray): KeyValue? { + if (sendPayload.size < 1 + BODY_BYTES) return null + if (sendPayload[0].toInt() != 0x01) return null + val name = StringBuilder() + for (i in 0 until NAME_FIELD_BYTES) { + val b = sendPayload[1 + i].toInt() and 0xFF + if (b == 0) { + // Everything after the name must be NUL, or this is not a NUL-padded name field. + for (j in i until NAME_FIELD_BYTES) { + if ((sendPayload[1 + j].toInt() and 0xFF) != 0) return null + } + break + } + if (b < 0x20 || b > 0x7E) return null + name.append(b.toChar()) + } + if (name.isEmpty()) return null + return KeyValue(name.toString(), sendPayload[1 + NAME_FIELD_BYTES].toInt() and 0xFF) + } + + /** + * **The send allowlist itself.** True only for SET_FF_VALUE(120) carrying a well-formed body whose + * key is one of the sixteen and whose value is that key's enable value or the off value. Every other + * opcode is false — explicitly including SET_DEVICE_CONFIG_VALUE(119), which keeps its own clause. + */ + fun admitsSend(opcode: Int, payload: ByteArray, deepDataOptIn: Boolean): Boolean { + if (!deepDataOptIn) return false + if (opcode != SET_FEATURE_FLAG_VALUE_CMD) return false + val kv = keyAndValue(payload) ?: return false + return isWritableValue(kv.value, kv.key) + } + + /** The read verb the post-write verification is allowed to send, and only that one. */ + fun isReadBackOpcode(opcode: Int): Boolean = opcode == GET_FEATURE_FLAG_VALUE_CMD +} + +/** + * #174: one R22 DISABLE run — write '0' to the sixteen feature flags, then read every one of them back + * and report the value the strap actually stores. Twin of the Swift `R22DisableReport`; the rendered text + * is byte-identical across platforms so a shared strap log reads the same either side. + * + * The run is staged because the off value is inferred (see [Whoop5Config.FEATURE_FLAG_OFF_VALUE]): + * 1. Probe — write '0' to ONE flag, read it back with GET_FF_VALUE(128). + * 2. Gate — if the strap did not stop reporting the old value, STOP. Fifteen keys stay untouched and the + * report says '0' is not how this namespace spells off, which is a real, publishable answer. + * 3. Clear — only on a good probe, write '0' to the remaining fifteen. + * 4. Verify — read all sixteen back and tabulate. + * + * Every write's own COMMAND_RESPONSE is recorded and NOT believed: SELECT_WRIST returns SUCCESS for a + * no-op and FAILURE for a real mutation on this firmware, so a result byte says nothing reliable about + * whether state moved. Only the value a 128 read returns is reported as state (#907/#891). + * + * Pure and order-dependent (nextStep -> note... -> nextStep), so the whole verdict table is unit-testable + * without a strap. + */ +class R22DisableReport( + val keys: List = Whoop5Config.enableR22Sequence.map { it.name }, +) { + + companion object { + /** + * The flag the probe stage writes first. + * + * `enable_sig12` is chosen because it is the ONLY key with a hardware demonstration that a write + * to it changes stored state: the enable sequence moved it from '2' (0x32) to '1' (0x31), + * confirmed by a GET_FF_VALUE(128) read before and after (#423, #103). If a '0' write to THIS key + * does not move the stored value, the failure 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 the + * least to lose: its effect is undocumented and it gates no stream NOOP reads. + */ + const val PROBE_KEY = "enable_sig12" + + /** The standing caveats, kept in one place so the UI, the log and the report cannot drift. */ + val CAVEATS = """ + What this does and does not establish: + • '0' as the off value is INFERRED. It is the confirmed off value in the device-config + namespace (SET_DEVICE_CONFIG_VALUE/119, hardware-validated on the Broadcast-HR flag, #181) + and that namespace shares this one's whole wire idiom — but no feature flag had ever been + observed holding '0' before this run. The read-back above is what turns that inference + into a measurement. + • A cleared flag is not the same as reverted BEHAVIOUR. This reports what the strap STORES. + Whether the deep records actually stop is a separate question, answered by wearing the + strap and watching the type-0x2F deep-buffer capture stop, not by this report. + • This does not restore a snapshot. NOOP never read these values before first writing them, + so the strap's pre-NOOP state is unknown. The honest claim is "cleared the flags NOOP set". + • disable_pip_r26_packets inverts: clearing it is expected to let PIP R26 packets flow AGAIN. + That is the pre-R22 behaviour and is intended. + """.trimIndent() + } + + /** Which stage of the run a step belongs to. */ + enum class Stage { PROBE_WRITE, PROBE_READ, CLEAR_WRITE, VERIFY_READ } + + /** One planned round-trip. */ + data class Step(val opcode: Int, val key: String, val stage: Stage) { + val isWrite: Boolean get() = stage == Stage.PROBE_WRITE || stage == Stage.CLEAR_WRITE + } + + /** What one key ended up in. CLEARED and UNSET are both successes. */ + enum class KeyOutcome(val label: String) { + /** Read back as '0': the key exists and now holds the off value. */ + CLEARED("cleared"), + /** The strap answered FAILURE for the key: it no longer has a stored value. The stronger result. */ + UNSET("unset"), + /** Read back as something other than '0' — the write did not take. */ + UNCHANGED("unchanged"), + /** The strap answered but did not echo the key, so no value can be claimed. */ + NOT_CLAIMED("notClaimed"), + /** No reply inside the step's window. */ + SILENT("silent"), + /** A reply arrived that could not be decoded. */ + UNDECODABLE("undecodable"), + /** The run stopped before this key was written. */ + SKIPPED("skipped"); + + val isSuccess: Boolean get() = this == CLEARED || this == UNSET + } + + /** The overall verdict. */ + enum class Verdict(val label: String) { + PENDING("pending"), + ALL_CLEARED("allCleared"), + PARTIAL("partial"), + /** The probe read-back showed the OLD value: '0' is not how this firmware clears a flag. */ + OFF_VALUE_REJECTED("offValueRejected"), + /** The probe read-back produced no usable answer. Distinct from a rejection on purpose: nothing + * was learned about the off value, so it must not be reported as evidence against it. */ + PROBE_INCONCLUSIVE("probeInconclusive"), + ABANDONED("abandoned"), + } + + /** One recorded read-back. */ + data class Reading( + val key: String, + val stage: Stage, + val value: Int?, + val resultCode: Int?, + val recordHex: String, + val outcome: KeyOutcome, + ) + + private var cursor = 0 + private var plan: MutableList = mutableListOf() + private var planBuilt = false + private var stopped = false + + val outcomes: MutableMap = linkedMapOf() + val readings: MutableList = mutableListOf() + val writeAcks: MutableMap = linkedMapOf() + val trace: MutableList = mutableListOf() + var verdict: Verdict = Verdict.PENDING + private set + var probePassed = false + private set + + init { + trace.add( + "R22 disable: writing '0' (0x30) to ${keys.size} feature flags via SET_FF_VALUE(120), " + + "each verified with GET_FF_VALUE(128). The write acks are recorded and NOT trusted." + ) + trace.add( + "Probe first: $PROBE_KEY is written and read back alone; the other " + + "${maxOf(0, keys.size - 1)} are only written if the strap stops reporting the old value." + ) + } + + /** + * The next round-trip, or null when the run is over. Built lazily in two halves so the gate is + * expressible: the probe pair up front, and the clear+verify tail only once the probe has passed. + */ + fun nextStep(): Step? { + if (plan.isEmpty() && !planBuilt) { + planBuilt = true + if (!keys.contains(PROBE_KEY)) { + // Defensive: a caller that hands over a key list without the probe key gets a plain + // sequential run rather than a silently skipped gate. + keys.forEach { plan.add(Step(FeatureFlagWriteGate.SET_FEATURE_FLAG_VALUE_CMD, it, Stage.CLEAR_WRITE)) } + keys.forEach { plan.add(Step(FeatureFlagWriteGate.GET_FEATURE_FLAG_VALUE_CMD, it, Stage.VERIFY_READ)) } + probePassed = true + return nextStep() + } + plan.add(Step(FeatureFlagWriteGate.SET_FEATURE_FLAG_VALUE_CMD, PROBE_KEY, Stage.PROBE_WRITE)) + plan.add(Step(FeatureFlagWriteGate.GET_FEATURE_FLAG_VALUE_CMD, PROBE_KEY, Stage.PROBE_READ)) + } + if (stopped) return null + if (cursor >= plan.size) { + if (probePassed && plan.none { it.stage == Stage.CLEAR_WRITE }) { + keys.filter { it != PROBE_KEY }.forEach { + plan.add(Step(FeatureFlagWriteGate.SET_FEATURE_FLAG_VALUE_CMD, it, Stage.CLEAR_WRITE)) + } + keys.forEach { plan.add(Step(FeatureFlagWriteGate.GET_FEATURE_FLAG_VALUE_CMD, it, Stage.VERIFY_READ)) } + } else { + return null + } + } + if (cursor >= plan.size) return null + return plan[cursor++] + } + + /** Record a write's own ack. Logged, never used to decide anything. */ + fun noteWriteAck(resultCode: Int?, step: Step) { + if (resultCode != null) writeAcks[step.key] = resultCode + val label = if (resultCode != null) "${FeatureFlagProbe.resultLabel(resultCode)}($resultCode)" else "(unlabelled)" + trace.add("SET_FF_VALUE(120) ${step.key}='0' → ack $label — recorded, not proof") + } + + /** Record a decoded read-back and score the key. */ + fun noteReadBack(r: DeviceConfigReadProbe.ValueResponse, step: Step) { + val value = r.valueFor(step.key) + val outcome = when { + r.isUnsupported -> KeyOutcome.UNCHANGED // the verb is refused; nothing can be claimed cleared + r.isFailure -> KeyOutcome.UNSET // no stored value for this key any more + value != null -> + if (value == Whoop5Config.FEATURE_FLAG_OFF_VALUE) KeyOutcome.CLEARED else KeyOutcome.UNCHANGED + else -> KeyOutcome.NOT_CLAIMED + } + record(Reading(step.key, step.stage, value, r.resultCode, r.recordHex, outcome), step) + } + + /** Record a read-back reply that could not be decoded. */ + fun noteReadFailure(f: DeviceConfigReadProbe.ParseFailure, step: Step) { + val why = when (f) { + DeviceConfigReadProbe.ParseFailure.CRC -> "CRC failed — frame rejected (never decoded)" + DeviceConfigReadProbe.ParseFailure.ENVELOPE -> "not a COMMAND_RESPONSE envelope" + DeviceConfigReadProbe.ParseFailure.WRONG_COMMAND -> "COMMAND_RESPONSE for a different command" + DeviceConfigReadProbe.ParseFailure.TRUNCATED -> "record too short to hold a response" + } + trace.add("GET_FF_VALUE(128) ${step.key} → reply not decoded: $why") + record(Reading(step.key, step.stage, null, null, "(undecodable)", KeyOutcome.UNDECODABLE), step) + } + + /** Record the strap answering nothing at all. */ + fun noteTimeout(step: Step, seconds: Int) { + val verb = if (step.isWrite) "SET_FF_VALUE(120)" else "GET_FF_VALUE(128)" + trace.add("$verb ${step.key} → no COMMAND_RESPONSE within ${seconds}s") + if (step.isWrite) return // a silent WRITE is fine; the read-back is what decides + record(Reading(step.key, step.stage, null, null, "(no reply)", KeyOutcome.SILENT), step) + } + + /** The link dropped, or the caller stopped the run. */ + fun noteAbandoned(why: String) { + trace.add("Run abandoned: $why") + stopped = true + keys.forEach { if (outcomes[it] == null) outcomes[it] = KeyOutcome.SKIPPED } + verdict = Verdict.ABANDONED + } + + private fun record(reading: Reading, step: Step) { + readings.add(reading) + var line = "GET_FF_VALUE(128) ${step.key}" + line += if (reading.resultCode != null) { + " → result=${FeatureFlagProbe.resultLabel(reading.resultCode)}(${reading.resultCode})" + } else " →" + if (reading.value != null) line += " value=${DeviceConfigReadProbe.valueLabel(reading.value)}" + line += " record=[${reading.recordHex}] ⇒ ${reading.outcome.label}" + trace.add(line) + + outcomes[step.key] = reading.outcome + + if (step.stage == Stage.PROBE_READ) { + probePassed = reading.outcome.isSuccess + if (!probePassed) { + stopped = true + keys.forEach { if (it != step.key) outcomes[it] = KeyOutcome.SKIPPED } + // ONLY an unmoved value is evidence against '0'. Silence, an undecodable frame and a reply + // that never echoed the key all mean "no usable answer". + verdict = if (reading.outcome == KeyOutcome.UNCHANGED) { + Verdict.OFF_VALUE_REJECTED + } else Verdict.PROBE_INCONCLUSIVE + trace.add(probeStopExplanation(reading.outcome)) + } else { + trace.add( + "Probe passed (${reading.outcome.label}) — '0' moves a feature flag on this firmware. " + + "Clearing the remaining ${maxOf(0, keys.size - 1)}." + ) + } + } + + if (!stopped && outcomes.size == keys.size && readings.any { it.stage == Stage.VERIFY_READ }) { + val cleared = keys.count { outcomes[it]?.isSuccess == true } + verdict = if (cleared == keys.size) Verdict.ALL_CLEARED else Verdict.PARTIAL + } + } + + private fun probeStopExplanation(outcome: KeyOutcome): String = when (outcome) { + KeyOutcome.UNCHANGED -> + "STOPPED: the strap still reports the old value for $PROBE_KEY, so '0' (0x30) is NOT how this " + + "firmware clears a feature flag. The other ${maxOf(0, keys.size - 1)} flags were left " + + "untouched. This is a real finding — please share this report on #174." + KeyOutcome.NOT_CLAIMED -> + "STOPPED: the strap answered but did not echo $PROBE_KEY, so no value can be claimed either " + + "way. Nothing further was written." + KeyOutcome.SILENT, KeyOutcome.UNDECODABLE -> + "STOPPED: no usable read-back for $PROBE_KEY, so whether the write landed is unknown. Nothing " + + "further was written." + else -> "STOPPED before clearing the remaining flags." + } + + /** One-line summary, suitable for a Settings row. */ + val summary: String + get() { + val cleared = keys.count { outcomes[it]?.isSuccess == true } + return when (verdict) { + Verdict.PENDING -> "Clearing R22 flags…" + Verdict.ALL_CLEARED -> "All ${keys.size} R22 flags cleared on the strap (read back, not just acked)." + Verdict.PARTIAL -> "$cleared of ${keys.size} R22 flags cleared — the rest did not take." + Verdict.OFF_VALUE_REJECTED -> "The strap refused '0' for $PROBE_KEY; the other flags were left alone." + Verdict.PROBE_INCONCLUSIVE -> "No usable read-back for $PROBE_KEY — nothing further was written." + Verdict.ABANDONED -> "Disable run interrupted — $cleared of ${keys.size} flags confirmed cleared." + } + } + + /** The full copyable report. */ + fun render(): String { + val sb = StringBuilder() + sb.append("#174 R22 DEEP-DATA DISABLE — WHOOP 5/MG\n") + sb.append("Wrote '0' (0x30) via SET_FF_VALUE(120) and read every key back with GET_FF_VALUE(128).\n") + sb.append("The write acks are recorded and NOT treated as proof: on this firmware a SUCCESS result byte\n") + sb.append("does not establish that state changed. Only the read-back is reported as state.\n") + sb.append("\nVerdict: ${verdict.label} — $summary\n") + sb.append("\nPer key:\n") + for (key in keys) { + val outcome = outcomes[key] ?: KeyOutcome.SKIPPED + val reading = readings.lastOrNull { it.key == key && it.stage == Stage.VERIFY_READ } + ?: readings.lastOrNull { it.key == key } + val was = Whoop5Config.enableR22Sequence.firstOrNull { it.name == key } + ?.let { DeviceConfigReadProbe.valueLabel(it.value) } ?: "?" + val now = reading?.value?.let { DeviceConfigReadProbe.valueLabel(it) } + ?: if (outcome == KeyOutcome.UNSET) "(no stored value)" else "(not claimed)" + sb.append(" ${DeviceConfigReadProbe.padded(key, 30)} enable wrote $was → now $now [${outcome.label}]\n") + } + sb.append("\nExchange:\n") + for (line in trace) sb.append(" ").append(line).append("\n") + sb.append("\n").append(CAVEATS) + return sb.toString() + } +} diff --git a/android/app/src/main/java/com/noop/protocol/Whoop5Config.kt b/android/app/src/main/java/com/noop/protocol/Whoop5Config.kt index b3960e4d67..955546266a 100644 --- a/android/app/src/main/java/com/noop/protocol/Whoop5Config.kt +++ b/android/app/src/main/java/com/noop/protocol/Whoop5Config.kt @@ -88,4 +88,61 @@ object Whoop5Config { seq = seq, payload = byteArrayOf(0x01) + payloadBody(flag.name, flag.value), ) + + // Turning it back off (#174) ------------------------------------------------------------------ + + /** + * The value byte written to clear a feature flag: ASCII '0' (0x30). + * + * **Confirmed for the sibling opcode, inferred here by shared convention.** + * + * Confirmed on real hardware: 0x30 is how this firmware spells "off" for a config key. NOOP has + * shipped that write since #181 — Broadcast-HR off writes 0x30 to `whoop_live_hr_in_adv_ind_pkt` + * through SET_DEVICE_CONFIG_VALUE (119 / 0x77) and a Garmin Edge 840 stops seeing the broadcast. + * `enable_raw_data_w_ecg` independently READS '0' on an MG whose ECG is not running. Both are the + * device-config namespace. + * + * Shared convention, which is why it transfers: both bodies are the key name as ASCII NUL-padded to + * 32 bytes then a single ASCII-digit value byte at offset 32, both are sent as `[0x01] + body` with + * response. Only the opcode (0x77 vs 0x78) and the body length (33 vs 40) differ. Writes through + * 0x78 demonstrably land and change stored state: the enable sequence moved `enable_sig12` from '2' + * to '1', confirmed by a GET_FF_VALUE(128) read before and after. + * + * Inferred, stated plainly: '0' has NEVER been observed in the feature-flag namespace — every 128 + * read ever taken returned '1' or '2', and those are a round-trip of NOOP's own writes. The two + * namespaces are proven separate at the verb level (a key asked through the wrong verb answers + * FAILURE). And `disable_pip_r26_packets` is written '2' despite being named `disable_*`, while + * `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", and tri-state semantics stay unestablished. + * + * So [R22DisableReport] tests the byte instead of asserting it: write it to ONE flag, read it back, + * and only touch the rest if the strap stopped reporting the old value. Keep in lockstep with the + * Swift `Whoop5Config.featureFlagOffValue`. + */ + const val FEATURE_FLAG_OFF_VALUE = 0x30 + + /** + * The inverse of [enableR22Sequence]: the same sixteen keys, in the same order, each carrying + * [FEATURE_FLAG_OFF_VALUE]. + * + * Same order as the enable sequence deliberately — `enable_r22_packets`, the master flag, is cleared + * FIRST, so a run interrupted by a disconnect leaves the strap nearer "off" than it started rather + * than stranded with sub-streams cleared and the master still set. + * + * On `disable_pip_r26_packets`, whose name inverts: this sequence is the UNDO of the enable sequence, + * not a per-key semantic inversion. Inverting per key would require knowing what '1' and '2' each + * select, and that key is the evidence we do not. 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. + * + * This does NOT restore a snapshot: NOOP never read these values before first writing them, so the + * pre-NOOP state is unknown. The honest claim is "clears the sixteen flags NOOP set". + * Keep in lockstep with the Swift `Whoop5Config.disableR22Sequence`. + */ + val disableR22Sequence: List = enableR22Sequence.map { Flag(it.name, FEATURE_FLAG_OFF_VALUE) } + + /** Every frame in the disable sequence, sequence-numbered from [firstSeq]. Written exactly like the + * enable frames — same opcode, same 40-byte body, same spacing, with response — because they differ + * only in the value byte. */ + fun disableSequenceFrames(firstSeq: Int = 1): List = + disableR22Sequence.mapIndexed { idx, flag -> frame(flag, (firstSeq + idx) and 0xFF) } } diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 45606f86da..c28c37fddd 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.text.font.FontFamily import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Autorenew @@ -74,6 +75,7 @@ import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -105,6 +107,10 @@ import com.noop.analytics.Baselines import com.noop.analytics.Zones import com.noop.R import com.noop.ble.PuffinExperiment +import com.noop.ble.WhoopBleClient +// #174: the R22 card reads the flag COUNT off Whoop5Config.enableR22Sequence rather than restating it — +// the hardcoded "15" outlived the sequence growing to 16 and declared success a flag early. +import com.noop.protocol.Whoop5Config import com.noop.ble.WhoopModel import com.noop.data.DataBackup import com.noop.ingest.RawSensorExport @@ -465,6 +471,17 @@ fun SettingsScreen( var puffinExperiments by remember(rev) { mutableStateOf(puffinExperiment.isEnabled) } var puffinCapture by remember(rev) { mutableStateOf(puffinExperiment.isCaptureEnabled) } var deepData by remember(rev) { mutableStateOf(puffinExperiment.isDeepDataEnabled) } + + // #174: set when the deep-data switch is turned OFF, so the app can OFFER to clear the flags on the + // strap instead of silently leaving them set. The switch alone has never written anything in either + // direction — it gates sends — so turning it off used to change nothing on the hardware while reading + // like an undo. Asking is the right shape rather than writing automatically: the strap may not be + // connected, and a write to bonded hardware is not something a toggle should do unannounced. + var confirmingDeepDataDisable by remember { mutableStateOf(false) } + val r22DisableReport by vm.ble.r22DisableReport.collectAsState() + // How many flags the enable sequence actually writes. Read from the sequence rather than restated, so + // the card cannot drift from it again — it said "15" for the whole life of the 16-flag sequence. + val r22FlagCount = Whoop5Config.enableR22Sequence.size var broadcastHr by remember(rev) { mutableStateOf(puffinExperiment.broadcastHr) } // "Sleep staging (V2)" — V2 is the DEFAULT for every strap (WHOOP 4 and 5/MG); turn it OFF to fall back // to V1. Model-agnostic, so it lives outside the 5/MG-only card. 4.0 is unvalidated either way (#319/#347). @@ -1869,6 +1886,11 @@ fun SettingsScreen( onCheckedChange = { deepData = it puffinExperiment.isDeepDataEnabled = it + // #174: turning the switch OFF used to write nothing — it only hid the enable + // button, so the strap kept every flag the sequence set while the UI implied it + // had been undone. Now it offers the real undo. Turning it ON still writes + // nothing until the button is tapped. + if (!it) confirmingDeepDataDisable = true }, colors = SwitchDefaults.colors( checkedThumbColor = Palette.surfaceBase, @@ -1902,13 +1924,35 @@ fun SettingsScreen( style = NoopType.caption, color = Palette.textTertiary, ) - // Live R22 telemetry (#174): proof of what the strap is doing right now. + // #174: the undo. Offered whenever the flags may be set — which is any time the + // opt-in has been on, not only right after a send, because the flags persist across + // launches and the app has no record of what a previous install wrote. Wear is NOT + // required: the on-wrist gate exists because the R22 STREAM is on-wrist only. + NoopButton( + text = uiString(R.string.l10n_settings_screen_turn_deep_data_back_off_r22disable), + leadingIcon = Icons.Filled.Cancel, + kind = NoopButtonKind.Secondary, + enabled = live.encryptedBond && r22DisableReport != WhoopBleClient.WAITING_DEVICE_CONFIG_PROBE, + onClick = { vm.ble.disableWhoop5DeepData() }, + ) + Text( + if (!live.encryptedBond) uiString(R.string.l10n_settings_screen_r22disable_needs_bond) + else uiString(R.string.l10n_settings_screen_r22disable_reason), + style = NoopType.caption, + color = Palette.textTertiary, + ) + // Live R22 telemetry (#174): proof of what the strap is doing right now. The threshold + // and the number are both driven off the sequence itself — they were hardcoded to 15 + // while the sequence carried 16, so the card declared success one flag early. if (live.r22FlagsAccepted > 0) { Text( - if (live.r22FlagsAccepted >= 15) "✓ Strap accepted all 15 R22 flags" - else "Strap accepted ${live.r22FlagsAccepted}/15 R22 flags…", + if (live.r22FlagsAccepted >= r22FlagCount) { + uiString(R.string.l10n_settings_screen_r22_accepted_all, r22FlagCount) + } else { + uiString(R.string.l10n_settings_screen_r22_accepted_partial, live.r22FlagsAccepted, r22FlagCount) + }, style = NoopType.caption, - color = if (live.r22FlagsAccepted >= 15) Palette.statusPositive else Palette.textSecondary, + color = if (live.r22FlagsAccepted >= r22FlagCount) Palette.statusPositive else Palette.textSecondary, ) } if (live.deepPacketsThisSession > 0) { @@ -1917,13 +1961,38 @@ fun SettingsScreen( style = NoopType.caption, color = Palette.textSecondary, ) - } else if (live.r22FlagsAccepted >= 15) { + } else if (live.r22FlagsAccepted >= r22FlagCount) { Text( uiString(R.string.l10n_settings_screen_flags_accepted_but_the_enable_sequence_542b2595), style = NoopType.caption, color = Palette.textTertiary, ) } + + // #174: the disable run's per-key result. Shown verbatim because the interesting part + // is the read-back table, not a green tick — a write that acked SUCCESS but did not + // move the stored value renders here as "unchanged", which is the case worth seeing. + val disableReport = r22DisableReport + if (disableReport != null) { + if (disableReport == WhoopBleClient.WAITING_DEVICE_CONFIG_PROBE) { + Text( + uiString(R.string.l10n_settings_screen_r22disable_running), + style = NoopType.caption, + color = Palette.textSecondary, + ) + } else { + Text( + disableReport, + style = NoopType.caption.copy(fontFamily = FontFamily.Monospace), + color = Palette.textSecondary, + ) + NoopButton( + text = uiString(R.string.l10n_settings_screen_r22disable_dismiss), + kind = NoopButtonKind.Secondary, + onClick = { vm.ble.clearR22DisableReport() }, + ) + } + } } Row( @@ -2302,6 +2371,49 @@ fun SettingsScreen( } } + // #174: the switch going OFF is the moment to offer the undo. Declining leaves the flags set and + // says so — still an improvement on the old behaviour, where the same tap silently left them set + // with no indication either way. + if (confirmingDeepDataDisable) { + AlertDialog( + onDismissRequest = { confirmingDeepDataDisable = false }, + containerColor = Palette.surfaceOverlay, + title = { + Text( + uiString(R.string.l10n_settings_screen_r22disable_confirm_title), + style = NoopType.title2, + color = Palette.textPrimary, + ) + }, + text = { + Text( + uiString(R.string.l10n_settings_screen_r22disable_confirm_body), + style = NoopType.subhead, + color = Palette.textSecondary, + ) + }, + confirmButton = { + TextButton(onClick = { + confirmingDeepDataDisable = false + vm.ble.disableWhoop5DeepData() + }) { + Text( + uiString(R.string.l10n_settings_screen_r22disable_confirm_action), + color = Palette.accent, + ) + } + }, + dismissButton = { + TextButton(onClick = { confirmingDeepDataDisable = false }) { + Text( + uiString(R.string.l10n_settings_screen_r22disable_confirm_cancel), + color = Palette.textSecondary, + ) + } + }, + ) + } + if (showRecalibrateConfirm) { AlertDialog( onDismissRequest = { showRecalibrateConfirm = false }, diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 7ccf6dde6d..a808d3aef8 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1784,4 +1784,15 @@ Energiesparmodus beruhigt die Anzeigen, übersetzte Android-Benachrichtigungen und sofort geladene Diagramme Kein SpO₂-Import- oder Health-Wert Nur Rohwerte — Import erforderlich + Tiefe Daten wieder ausschalten + Benötigt die vollständig verschlüsselte Kopplung: Schließe zuerst die offizielle WHOOP-App und koppele das Band mit NOOP (eine Verbindung nur für den Live-Puls kann den Schreibvorgang nicht übertragen). + Schreibt den Aus-Wert in alle 16 Flags und liest jedes einzeln zurück, sodass du siehst, was das Band tatsächlich speichert — nicht nur, dass es bestätigt hat. Der Aus-Wert ist von dem Flag abgeleitet, das NOOP für die Garmin-Übertragung bereits so ausschaltet; auf einem R22-Flag wurde er noch nie beobachtet. Deshalb testet NOOP zuerst ein einzelnes Flag und bricht ab, wenn das Band es ablehnt. Gelöschte Flags sind nicht dasselbe wie tatsächlich versiegende Tiefendaten: prüfe das mit einer anschließenden Synchronisierung. + R22-Flags werden gelöscht und einzeln zurückgelesen… + Deaktivierungsbericht schließen + R22-Flags auf dem Band löschen? + Diesen Schalter auszuschalten stoppt nur, dass NOOP die Freischaltung sendet. Die bereits geschriebenen Flags bleiben auf dem Band, bis sie etwas löscht. NOOP kann jetzt den Aus-Wert in alle 16 schreiben und jedes zurücklesen, damit du siehst, was das Band tatsächlich speichert. Das Band muss verbunden und gekoppelt sein. + Flags auf dem Band löschen + Nur nicht mehr senden + ✓ Band hat alle %1$d R22-Flags akzeptiert + Band hat %1$d/%2$d R22-Flags akzeptiert… diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index d1bc1da1c9..46b3117894 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1769,4 +1769,15 @@ El ahorro de batería calma los indicadores, notificaciones de Android traducidas y gráficos instantáneos Sin importación de SpO₂ ni valor de Health Solo valores brutos — requiere importación + Volver a desactivar los datos profundos + Necesita el vínculo cifrado completo: cierra primero la app oficial de WHOOP y empareja la pulsera con NOOP (un enlace solo de FC en vivo no puede transportar la escritura). + Escribe el valor de apagado en las 16 marcas y vuelve a leer cada una, para que veas lo que la pulsera guarda en vez de solo que confirmó. El valor de apagado se deduce de la marca que NOOP ya desactiva así para la difusión Garmin; nunca se ha visto en una marca R22, por eso NOOP prueba primero con una sola y se detiene si la pulsera la rechaza. Borrar las marcas no es lo mismo que ver parar los registros profundos: compruébalo sincronizando después. + Borrando las marcas R22 y releyendo cada una… + Descartar el informe de desactivación + ¿Borrar las marcas R22 de la pulsera? + Apagar este interruptor solo impide que NOOP envíe el desbloqueo. Las marcas ya escritas siguen en la pulsera hasta que algo las borre. NOOP puede escribir ahora el valor de apagado en las 16 y releer cada una para que veas lo que la pulsera guarda realmente. Requiere la pulsera conectada y vinculada. + Borrar marcas en la pulsera + Solo dejar de enviar + ✓ La pulsera aceptó las %1$d marcas R22 + La pulsera aceptó %1$d/%2$d marcas R22… diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 0a885526cc..cad314ce74 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1769,4 +1769,15 @@ L\'économiseur de batterie fige les jauges, notifications Android traduites et graphiques instantanés Aucune importation SpO₂ ni valeur Santé Valeurs brutes uniquement — importation requise + Désactiver à nouveau les données détaillées + Nécessite l’appairage chiffré complet : fermez d’abord l’app WHOOP officielle et appairez le bracelet à NOOP (une liaison limitée à la FC en direct ne peut pas transporter l’écriture). + Écrit la valeur d’arrêt dans les 16 indicateurs et relit chacun d’eux, pour que vous voyiez ce que le bracelet enregistre réellement et pas seulement qu’il a accusé réception. La valeur d’arrêt est déduite de l’indicateur que NOOP désactive déjà ainsi pour la diffusion Garmin ; elle n’a jamais été observée sur un indicateur R22, c’est pourquoi NOOP en teste un seul d’abord et s’arrête si le bracelet le refuse. Effacer les indicateurs n’équivaut pas à voir cesser les enregistrements détaillés : vérifiez-le en synchronisant ensuite. + Effacement des indicateurs R22 et relecture de chacun… + Fermer le rapport de désactivation + Effacer les indicateurs R22 sur votre bracelet ? + Désactiver cet interrupteur empêche seulement NOOP d’envoyer le déverrouillage. Les indicateurs déjà écrits restent sur le bracelet jusqu’à ce que quelque chose les efface. NOOP peut maintenant écrire la valeur d’arrêt dans les 16 et relire chacun d’eux pour que vous voyiez ce que le bracelet enregistre réellement. Nécessite le bracelet connecté et appairé. + Effacer les indicateurs + Cesser seulement d’envoyer + ✓ Le bracelet a accepté les %1$d indicateurs R22 + Le bracelet a accepté %1$d/%2$d indicateurs R22… diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index 3feb1bc27c..a965f5c56b 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -1763,4 +1763,15 @@ A poupança de bateria acalma os indicadores, notificações Android traduzidas e gráficos instantâneos Sem importação de SpO₂ ou valor de saúde Apenas valores brutos — requer importação + Voltar a desativar os dados profundos + Necessita da ligação encriptada completa: feche primeiro a app oficial da WHOOP e emparelhe a pulseira com o NOOP (uma ligação apenas de FC em direto não consegue transportar a escrita). + Escreve o valor de desativação nas 16 marcas e lê cada uma de volta, para que veja o que a pulseira guarda e não apenas que confirmou. O valor de desativação é inferido da marca que o NOOP já desliga assim para a transmissão Garmin — nunca foi visto numa marca R22, por isso o NOOP testa primeiro uma só e pára se a pulseira a recusar. Limpar as marcas não é o mesmo que ver os registos profundos pararem: confirme isso sincronizando depois. + A limpar as marcas R22 e a reler cada uma… + Dispensar o relatório de desativação + Limpar as marcas R22 na pulseira? + Desligar este interruptor apenas impede o NOOP de enviar o desbloqueio. As marcas já escritas permanecem na pulseira até que algo as limpe. O NOOP pode agora escrever o valor de desativação nas 16 e reler cada uma para que veja o que a pulseira guarda realmente. Necessita da pulseira ligada e emparelhada. + Limpar marcas na pulseira + Apenas parar de enviar + ✓ A pulseira aceitou as %1$d marcas R22 + A pulseira aceitou %1$d/%2$d marcas R22… diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 9dc7a7a8d0..3477bd3cc5 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1697,4 +1697,15 @@ 省电模式让仪表静止、Android 通知已翻译、图表秒开 无 SpO₂ 导入或健康数值 仅原始计数 — 需导入 + ✓ 手环已接受全部 %1$d 个 R22 标记 + 手环已接受 %1$d/%2$d 个 R22 标记… + 重新关闭深层数据 + 需要完整的加密配对:请先关闭官方 WHOOP 应用并将手环与 NOOP 配对(仅传输实时心率的连接无法承载写入)。 + 向全部 16 个标记写入关闭值,并逐一读回,让你看到手环实际存储的内容,而不仅仅是它已确认。该关闭值是从 NOOP 已用同样方式关闭的 Garmin 广播标记推断出来的——在 R22 标记上从未被观察到,因此 NOOP 会先试一个标记,若手环拒绝就停止。清除标记与看到深层记录停止并不相同:之后同步一次来确认。 + 正在清除 R22 标记并逐一读回… + 关闭停用报告 + 要清除手环上的 R22 标记吗? + 关闭此开关只会让 NOOP 停止发送解锁序列。已写入的标记会留在手环上,直到有东西清除它们。NOOP 现在可以向全部 16 个标记写入关闭值并逐一读回,让你看到手环实际存储的内容。需要手环已连接并完成配对。 + 清除手环上的标记 + 仅停止发送 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index bb84b8be5a..a9131e8770 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1795,4 +1795,15 @@ Battery saver quiets the gauges, translated Android notifications, and instant chart loads No SpO₂ import or Health value Raw counts only — needs an import + Turn deep data back off + Needs the full encrypted bond: close the official WHOOP app and pair the strap to NOOP first (a live-HR-only link can’t carry the write). + Writes the off value to all 16 flags and reads each one back, so you see what the strap stores rather than just that it acked. The off value is inferred from the flag NOOP already turns off this way for Garmin broadcast — it has never been seen on an R22 flag, so NOOP tries one flag first and stops if the strap refuses it. Clearing the flags is not the same as watching the deep records stop: check that by syncing afterwards. + Clearing R22 flags and reading each one back… + Dismiss disable report + Clear the R22 flags on your strap? + Turning this switch off only stops NOOP sending the unlock. The flags it already wrote stay on the strap until something clears them. NOOP can write the off value to all 16 now and read each one back so you can see what the strap actually stores. Needs the strap connected and bonded. + Clear flags on strap + Just stop sending + ✓ Strap accepted all %1$d R22 flags + Strap accepted %1$d/%2$d R22 flags… diff --git a/android/app/src/test/java/com/noop/protocol/R22DisableTest.kt b/android/app/src/test/java/com/noop/protocol/R22DisableTest.kt new file mode 100644 index 0000000000..c0d7e2f46d --- /dev/null +++ b/android/app/src/test/java/com/noop/protocol/R22DisableTest.kt @@ -0,0 +1,353 @@ +package com.noop.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * #174: the R22 DISABLE path — the sequence bytes, the key-aware SET_FF_VALUE(120) allowlist, and the + * staged write→read-back run. Twin of the Swift `R22DisableTests`; keep the assertions in lockstep. + * + * Everything here runs with no app, no strap and no Bluetooth. That matters more than usual: the one + * thing that CANNOT be tested off-strap is whether the firmware accepts the off value, and the design is + * built so the run itself answers that on first use rather than assuming it. + */ +class R22DisableTest { + + // The sequence ------------------------------------------------------------------------------ + + @Test + fun disableSequenceMirrorsEnableExactly() { + val enable = Whoop5Config.enableR22Sequence + val disable = Whoop5Config.disableR22Sequence + assertEquals("the undo must cover every key the enable sets", enable.size, disable.size) + assertEquals(enable.map { it.name }, disable.map { it.name }) + assertTrue(disable.all { it.value == Whoop5Config.FEATURE_FLAG_OFF_VALUE }) + } + + @Test + fun offValueIsAsciiZero() { + assertEquals(0x30, Whoop5Config.FEATURE_FLAG_OFF_VALUE) + assertEquals('0'.code, Whoop5Config.FEATURE_FLAG_OFF_VALUE) + } + + /** The disable frame differs from the enable frame in EXACTLY one byte — the value at frame offset 44 + * — plus the CRC32 trailer it necessarily changes. */ + @Test + fun disableFrameDiffersFromEnableInOnlyTheValueByte() { + val on = Whoop5Config.frame(Whoop5Config.Flag("enable_r22_packets", 0x32), 1) + val off = Whoop5Config.frame(Whoop5Config.Flag("enable_r22_packets", 0x30), 1) + assertEquals(on.size, off.size) + val differing = on.indices.filter { on[it] != off[it] } + assertEquals("the value byte sits at frame offset 44", 44, differing.first()) + assertEquals("value byte + CRC32 trailer only", 5, differing.size) + assertEquals(0x30, off[44].toInt() and 0xFF) + } + + @Test + fun disableFramesVerifyAndCarryDistinctSeqs() { + val frames = Whoop5Config.disableSequenceFrames(1) + assertEquals(16, frames.size) + assertEquals((1..16).toList(), frames.map { it[9].toInt() and 0xFF }) + for (f in frames) { + assertTrue("every disable frame must pass 5/MG CRCs", Framing.frameCrcOk(f, DeviceFamily.WHOOP5)) + } + } + + /** `disable_pip_r26_packets` gets the SAME off byte as the `enable_*` keys: this sequence is the undo + * of the enable sequence, not a per-key semantic inversion. Pinned because it is the one line a + * reviewer is most likely to "fix". */ + @Test + fun disablePipR26GetsTheSameOffByteAsTheEnableKeys() { + val pip = Whoop5Config.disableR22Sequence.firstOrNull { it.name == "disable_pip_r26_packets" } + assertEquals(Whoop5Config.FEATURE_FLAG_OFF_VALUE, pip?.value) + assertEquals( + "and the enable value stays '2' — this key is why boolean semantics are not assumed", + 0x32, + Whoop5Config.enableR22Sequence.firstOrNull { it.name == "disable_pip_r26_packets" }?.value, + ) + } + + // The write gate ---------------------------------------------------------------------------- + + @Test + fun gateAdmitsOnlyOpcode120() { + val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody("enable_r22_packets", 0x30) + for (opcode in 0..255) { + assertEquals( + "opcode $opcode admission", + opcode == 120, + FeatureFlagWriteGate.admitsSend(opcode, payload, deepDataOptIn = true), + ) + } + } + + @Test + fun gateRefusesDeviceConfigWriteVerb() { + val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody("enable_r22_packets", 0x30) + assertFalse( + "119 keeps its own Broadcast-HR clause and must never be reachable from here", + FeatureFlagWriteGate.admitsSend(119, payload, deepDataOptIn = true), + ) + } + + @Test + fun gateRefusesEverythingWithoutTheOptIn() { + for (flag in Whoop5Config.enableR22Sequence) { + for (value in listOf(flag.value, Whoop5Config.FEATURE_FLAG_OFF_VALUE)) { + val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody(flag.name, value) + assertFalse( + "${flag.name}=$value must be refused with the opt-in off", + FeatureFlagWriteGate.admitsSend(120, payload, deepDataOptIn = false), + ) + } + } + } + + @Test + fun gateAdmitsEveryR22KeyForBothItsEnableValueAndOff() { + for (flag in Whoop5Config.enableR22Sequence) { + for (value in listOf(flag.value, Whoop5Config.FEATURE_FLAG_OFF_VALUE)) { + val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody(flag.name, value) + assertTrue( + "${flag.name}=$value must be admitted", + FeatureFlagWriteGate.admitsSend(120, payload, deepDataOptIn = true), + ) + } + } + } + + /** The tightening this gate exists for: before it, opcode 120 was admitted on the opt-in alone, so ANY + * feature-flag key with ANY value could travel. Both halves of that are now closed. */ + @Test + fun gateRefusesUnknownKeysAndUnknownValues() { + for (key in listOf("general_ab_test", "enable_pdaf_walk_det", "enable_maverick_model", "enable_r22_v7_packets")) { + val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody(key, 0x30) + assertFalse("$key is not an R22 key", FeatureFlagWriteGate.admitsSend(120, payload, deepDataOptIn = true)) + } + for (value in listOf(0x00, 0x29, 0x33, 0x39, 0x41, 0xFF)) { + val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody("enable_r22_packets", value) + assertFalse( + "value 0x%02x is neither the enable nor the off value".format(value), + FeatureFlagWriteGate.admitsSend(120, payload, deepDataOptIn = true), + ) + } + // enable_r22_v4_packets' enable value is '1', so '2' must be refused for THAT key specifically. + val wrongForV4 = byteArrayOf(0x01) + Whoop5Config.payloadBody("enable_r22_v4_packets", 0x32) + assertFalse( + "the admitted value is per-key, not a shared pair", + FeatureFlagWriteGate.admitsSend(120, wrongForV4, deepDataOptIn = true), + ) + } + + @Test + fun gateRefusesMalformedBodies() { + val good = Whoop5Config.payloadBody("enable_r22_packets", 0x30) + assertFalse(FeatureFlagWriteGate.admitsSend(120, byteArrayOf(0x02) + good, deepDataOptIn = true)) + assertFalse(FeatureFlagWriteGate.admitsSend(120, byteArrayOf(0x01) + good.copyOfRange(0, good.size - 1), deepDataOptIn = true)) + assertFalse(FeatureFlagWriteGate.admitsSend(120, ByteArray(0), deepDataOptIn = true)) + val dirty = good.copyOf(); dirty[25] = 0x41 + assertFalse( + "a name field whose padding is not NUL is not a name field", + FeatureFlagWriteGate.admitsSend(120, byteArrayOf(0x01) + dirty, deepDataOptIn = true), + ) + val binary = good.copyOf(); binary[3] = 0x01 + assertFalse(FeatureFlagWriteGate.admitsSend(120, byteArrayOf(0x01) + binary, deepDataOptIn = true)) + } + + @Test + fun keyAndValueRoundTripsEveryR22Key() { + for (flag in Whoop5Config.enableR22Sequence) { + val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody(flag.name, Whoop5Config.FEATURE_FLAG_OFF_VALUE) + val parsed = FeatureFlagWriteGate.keyAndValue(payload) + assertEquals(flag.name, parsed?.key) + assertEquals(0x30, parsed?.value) + } + } + + @Test + fun readBackOpcodeIsOnly128() { + for (opcode in 0..255) { + assertEquals(opcode == 128, FeatureFlagWriteGate.isReadBackOpcode(opcode)) + } + } + + // The staged run ---------------------------------------------------------------------------- + + /** A synthetic GET_FF_VALUE reply record: the key NUL-padded to 32 bytes, then the value byte. */ + private fun reply(key: String, value: Int, resultCode: Int = 1): DeviceConfigReadProbe.ValueResponse { + val record = ByteArray(33) + val bytes = key.toByteArray(Charsets.US_ASCII) + for (i in bytes.indices) if (i < 32) record[i] = bytes[i] + record[32] = (value and 0xFF).toByte() + return DeviceConfigReadProbe.ValueResponse(resultCode, record) + } + + private fun failureReply() = DeviceConfigReadProbe.ValueResponse(0, ByteArray(0)) + + @Test + fun planStartsWithTheProbeWriteThenItsReadBack() { + val r = R22DisableReport() + val first = r.nextStep() + assertEquals(R22DisableReport.Stage.PROBE_WRITE, first?.stage) + assertEquals(R22DisableReport.PROBE_KEY, first?.key) + assertEquals(120, first?.opcode) + val second = r.nextStep() + assertEquals(R22DisableReport.Stage.PROBE_READ, second?.stage) + assertEquals(128, second?.opcode) + } + + /** The gate: a probe read-back still showing the old value STOPS the run with fifteen keys untouched. */ + @Test + fun probeRejectionStopsTheRunAndLeavesFifteenKeysUntouched() { + val r = R22DisableReport() + val write = r.nextStep()!! + r.noteWriteAck(1, write) // SUCCESS ack — deliberately not believed + val read = r.nextStep()!! + r.noteReadBack(reply(R22DisableReport.PROBE_KEY, 0x31), read) + + assertNull("no further steps once the off value is rejected", r.nextStep()) + assertEquals(R22DisableReport.Verdict.OFF_VALUE_REJECTED, r.verdict) + assertEquals(R22DisableReport.KeyOutcome.UNCHANGED, r.outcomes[R22DisableReport.PROBE_KEY]) + assertEquals(15, r.keys.count { r.outcomes[it] == R22DisableReport.KeyOutcome.SKIPPED }) + assertTrue(r.render().contains("is NOT how this firmware clears a feature flag")) + assertEquals(1, r.writeAcks[R22DisableReport.PROBE_KEY]) + } + + @Test + fun fullRunClearsAllSixteen() { + val r = R22DisableReport() + var steps = 0 + while (true) { + val step = r.nextStep() ?: break + steps++ + assertTrue("plan must terminate", steps < 100) + if (step.isWrite) { + assertEquals(120, step.opcode) + r.noteWriteAck(1, step) + } else { + assertEquals(128, step.opcode) + r.noteReadBack(reply(step.key, 0x30), step) + } + } + assertEquals(R22DisableReport.Verdict.ALL_CLEARED, r.verdict) + assertEquals(16, r.keys.size) + for (key in r.keys) assertEquals("$key should be cleared", R22DisableReport.KeyOutcome.CLEARED, r.outcomes[key]) + // 1 probe write + 1 probe read + 15 clear writes + 16 verify reads + assertEquals(33, steps) + assertTrue(r.summary.contains("All 16 R22 flags cleared")) + } + + /** FAILURE on a read-back means the key no longer has a stored value at all — its own state, not + * folded into "cleared". */ + @Test + fun failureReadBackIsReportedAsUnsetNotCleared() { + val r = R22DisableReport() + r.noteWriteAck(1, r.nextStep()!!) + r.noteReadBack(failureReply(), r.nextStep()!!) + assertEquals(R22DisableReport.KeyOutcome.UNSET, r.outcomes[R22DisableReport.PROBE_KEY]) + assertTrue("an unset key is a success — the run should continue", r.probePassed) + assertNotNull("the clear stage must follow a passing probe", r.nextStep()) + } + + @Test + fun partialRunIsReportedAsPartialNotSuccess() { + val r = R22DisableReport() + while (true) { + val step = r.nextStep() ?: break + when { + step.isWrite -> r.noteWriteAck(1, step) + step.key == "wear_detect_bias" -> r.noteReadBack(reply(step.key, 0x32), step) + else -> r.noteReadBack(reply(step.key, 0x30), step) + } + } + assertEquals(R22DisableReport.Verdict.PARTIAL, r.verdict) + assertEquals(R22DisableReport.KeyOutcome.UNCHANGED, r.outcomes["wear_detect_bias"]) + assertTrue(r.summary.contains("15 of 16")) + } + + /** A SUCCESS ack whose read-back did not move must never render as success — the #907/#891 rule. */ + @Test + fun successAckWithUnmovedValueIsNeverReportedAsSuccess() { + val r = R22DisableReport() + r.noteWriteAck(1, r.nextStep()!!) + r.noteReadBack(reply(R22DisableReport.PROBE_KEY, 0x31), r.nextStep()!!) + val text = r.render() + assertFalse(r.verdict == R22DisableReport.Verdict.ALL_CLEARED) + assertTrue(text.contains("recorded, not proof")) + assertTrue(text.contains("NOT treated as proof")) + } + + @Test + fun silentProbeStopsTheRun() { + val r = R22DisableReport() + val write = r.nextStep()!! + r.noteTimeout(write, 8) + assertNull("a silent WRITE decides nothing — only the read-back does", r.outcomes[R22DisableReport.PROBE_KEY]) + r.noteTimeout(r.nextStep()!!, 8) + assertEquals(R22DisableReport.Verdict.PROBE_INCONCLUSIVE, r.verdict) + assertNull(r.nextStep()) + } + + @Test + fun abandonMarksRemainingKeysSkipped() { + val r = R22DisableReport() + r.noteWriteAck(1, r.nextStep()!!) + r.noteAbandoned("link dropped") + assertEquals(R22DisableReport.Verdict.ABANDONED, r.verdict) + assertNull(r.nextStep()) + assertEquals(16, r.keys.count { r.outcomes[it] == R22DisableReport.KeyOutcome.SKIPPED }) + } + + @Test + fun undecodableReadBackIsNotTreatedAsCleared() { + val r = R22DisableReport() + r.noteWriteAck(1, r.nextStep()!!) + r.noteReadFailure(DeviceConfigReadProbe.ParseFailure.CRC, r.nextStep()!!) + assertEquals(R22DisableReport.KeyOutcome.UNDECODABLE, r.outcomes[R22DisableReport.PROBE_KEY]) + assertFalse(r.probePassed) + assertEquals(R22DisableReport.Verdict.PROBE_INCONCLUSIVE, r.verdict) + } + + /** The report must state the four standing caveats, so the log, the UI and the PR cannot drift on what + * was actually established. */ + @Test + fun renderCarriesTheCaveats() { + val r = R22DisableReport() + while (true) { + val step = r.nextStep() ?: break + if (step.isWrite) r.noteWriteAck(1, step) else r.noteReadBack(reply(step.key, 0x30), step) + } + val text = r.render() + assertTrue(text.contains("'0' as the off value is INFERRED")) + assertTrue(text.contains("not the same as reverted BEHAVIOUR")) + assertTrue(text.contains("does not restore a snapshot")) + assertTrue(text.contains("disable_pip_r26_packets inverts")) + for (key in r.keys) assertTrue("$key missing from the report", text.contains(key)) + } + + @Test + fun probeKeyIsTheOneWithAHardwareWriteDemonstration() { + assertEquals("enable_sig12", R22DisableReport.PROBE_KEY) + assertTrue(Whoop5Config.enableR22Sequence.any { it.name == R22DisableReport.PROBE_KEY }) + } + + /** Cross-platform parity: the Swift and Kotlin reports must render the same verdict labels, or a + * shared strap log reads differently on the two platforms. */ + @Test + fun verdictAndOutcomeLabelsMatchTheSwiftSpelling() { + assertEquals("allCleared", R22DisableReport.Verdict.ALL_CLEARED.label) + assertEquals("partial", R22DisableReport.Verdict.PARTIAL.label) + assertEquals("offValueRejected", R22DisableReport.Verdict.OFF_VALUE_REJECTED.label) + assertEquals("probeInconclusive", R22DisableReport.Verdict.PROBE_INCONCLUSIVE.label) + assertEquals("abandoned", R22DisableReport.Verdict.ABANDONED.label) + assertEquals("cleared", R22DisableReport.KeyOutcome.CLEARED.label) + assertEquals("unset", R22DisableReport.KeyOutcome.UNSET.label) + assertEquals("unchanged", R22DisableReport.KeyOutcome.UNCHANGED.label) + assertEquals("notClaimed", R22DisableReport.KeyOutcome.NOT_CLAIMED.label) + assertEquals("skipped", R22DisableReport.KeyOutcome.SKIPPED.label) + } +} From a9713deba51fad707dea09f2f1c0e4265d328ebe Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:46:02 -0400 Subject: [PATCH 2/2] Fix the toggle-off path: gate the R22 undo on the run, not the pref (#174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Sources/WhoopProtocol/R22Disable.swift | 96 +++++++--- .../WhoopProtocolTests/R22DisableTests.swift | 148 ++++++++++++--- Strand/BLE/BLEManager.swift | 51 +++-- Strand/Screens/SettingsView.swift | 42 +++-- .../main/java/com/noop/ble/WhoopBleClient.kt | 42 +++-- .../main/java/com/noop/protocol/R22Disable.kt | 82 ++++++-- .../main/java/com/noop/ui/SettingsScreen.kt | 53 +++--- .../java/com/noop/protocol/R22DisableTest.kt | 176 +++++++++++++++--- 8 files changed, 528 insertions(+), 162 deletions(-) diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/R22Disable.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/R22Disable.swift index 1e6c641f40..9d058bb270 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/R22Disable.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/R22Disable.swift @@ -17,14 +17,28 @@ import Foundation /// /// ## What it admits /// -/// | opcode | key | value | gate | -/// |---|---|---|---| -/// | 120 `SET_FF_VALUE` | one of `Whoop5Config.enableR22Sequence` | the enable value for that key, or `featureFlagOffValue` | deep-data opt-in | -/// | anything else | — | — | refused | +/// Two predicates, not one, because the enable and the disable directions have **different gates** as well +/// as different values: /// -/// A value outside that pair is refused even for an R22 key: nothing in NOOP has a reason to write a third -/// value, and admitting one would mean a typo could put an arbitrary byte into persistent strap config. -/// `SET_DEVICE_CONFIG_VALUE` (119) is refused outright — it keeps its own clause for the Broadcast-HR flag. +/// | predicate | opcode | key | value | gate | +/// |---|---|---|---|---| +/// | `admitsEnableWrite` | 120 `SET_FF_VALUE` | one of `Whoop5Config.enableR22Sequence` | that key's own enable value | deep-data opt-in | +/// | `admitsDisableWrite` | 120 `SET_FF_VALUE` | one of `Whoop5Config.enableR22Sequence` | `featureFlagOffValue` only | a disable run in flight | +/// | anything else | — | — | — | refused | +/// +/// **Why the disable direction is gated on the run and not on the pref.** `deepDataEnabled` is bound +/// straight to a SwiftUI `@AppStorage` switch, so it is already `false` by the time the user confirms they +/// want the flags cleared — gating the undo on it makes the undo unreachable from the one control a user +/// naturally reaches for. The run itself is the honest gate: it is only ever non-nil inside +/// `disableWhoop5DeepData()`, which is user-initiated, and it closes again the moment the run ends. This is +/// the same shape `isReadBackOpcode` + `r22DisableRun != nil` already uses for the 128 read-back. +/// +/// Splitting the predicates also makes the invariant exact rather than approximate: **an off value on the +/// wire means a disable run is in flight**, and an enable value on the wire means the opt-in is on. A single +/// predicate admitting either value under either gate could not say that. A value outside the pair is +/// refused even for an R22 key: nothing in NOOP has a reason to write a third value, and admitting one would +/// mean a typo could put an arbitrary byte into persistent strap config. `SET_DEVICE_CONFIG_VALUE` (119) is +/// refused outright — it keeps its own clause for the Broadcast-HR flag. /// /// Pure: no CoreBluetooth, no I/O, no preferences. The app layer supplies the opt-in boolean. The Kotlin /// twin is `com.noop.protocol.FeatureFlagWriteGate` — keep them byte-identical. @@ -53,11 +67,17 @@ public enum FeatureFlagWriteGate { Whoop5Config.enableR22Sequence.first { $0.name == key }?.value } - /// Whether `value` is one NOOP is allowed to write for `key`: that key's own enable value, or the - /// documented off value. Anything else is refused. - public static func isWritableValue(_ value: UInt8, for key: String) -> Bool { + /// Whether `value` is the ENABLE value for `key` — the only value the enable direction may write. + public static func isEnableValue(_ value: UInt8, for key: String) -> Bool { guard let on = enableValue(for: key) else { return false } - return value == on || value == Whoop5Config.featureFlagOffValue + return value == on + } + + /// Whether `key` is one of the sixteen AND `value` is the documented off value — the only pair the + /// disable direction may write. The key check is what stops the off value reaching an arbitrary flag. + public static func isOffValue(_ value: UInt8, for key: String) -> Bool { + guard enableValue(for: key) != nil else { return false } + return value == Whoop5Config.featureFlagOffValue } // MARK: - Body parsing @@ -93,16 +113,34 @@ public enum FeatureFlagWriteGate { // MARK: - The allowlist predicate - /// **The send allowlist itself.** True only for `SET_FF_VALUE(120)` carrying a well-formed body whose - /// key is one of the sixteen and whose value is that key's enable value or the off value. + /// **The enable direction's send allowlist.** True only for `SET_FF_VALUE(120)` carrying a well-formed + /// body whose key is one of the sixteen and whose value is that key's own enable value, while the + /// deep-data opt-in is on. /// /// Every other opcode is false — explicitly including `SET_DEVICE_CONFIG_VALUE(119)`, which keeps its - /// own separate clause and must never be reachable from here. - public static func admitsSend(opcode: UInt8, payload: [UInt8], deepDataOptIn: Bool) -> Bool { + /// own separate clause and must never be reachable from here. The off value is NOT admitted here; it + /// belongs to `admitsDisableWrite` and its own gate. + public static func admitsEnableWrite(opcode: UInt8, payload: [UInt8], deepDataOptIn: Bool) -> Bool { guard deepDataOptIn else { return false } guard opcode == setFeatureFlagValueCmd else { return false } guard let (key, value) = keyAndValue(inSendPayload: payload) else { return false } - return isWritableValue(value, for: key) + return isEnableValue(value, for: key) + } + + /// **The disable direction's send allowlist.** True only for `SET_FF_VALUE(120)` carrying a well-formed + /// body whose key is one of the sixteen and whose value is `featureFlagOffValue`, while a disable run is + /// actually in flight. + /// + /// `disableRunInFlight` is `r22DisableRun != nil` at the call site — deliberately NOT the deep-data + /// preference. The preference is already `false` by the time a user confirms the undo (the switch writes + /// it before the confirmation dialog is raised), so gating on it makes the undo unreachable from the + /// toggle. Gating on the run is also strictly narrower in the other direction: with the opt-in merely + /// left on, no off value can be sent unless a run is genuinely walking its plan. + public static func admitsDisableWrite(opcode: UInt8, payload: [UInt8], disableRunInFlight: Bool) -> Bool { + guard disableRunInFlight else { return false } + guard opcode == setFeatureFlagValueCmd else { return false } + guard let (key, value) = keyAndValue(inSendPayload: payload) else { return false } + return isOffValue(value, for: key) } /// The read verb the post-write verification is allowed to send, and only that one. @@ -223,6 +261,10 @@ public struct R22DisableReport: Equatable, Sendable { /// Distinct from `offValueRejected` on purpose: nothing was learned about the off value, so this /// must not be reported as evidence against it. case probeInconclusive + /// The run refused to start because the key list does not contain `probeKey`, so the off value could + /// not be tested on one flag before the rest were written. Nothing was sent. Distinct from + /// `probeInconclusive`: there the probe ran and answered nothing, here it could not run at all. + case probeUnavailable /// The run was abandoned (disconnect, or the caller stopped it). case abandoned } @@ -287,12 +329,21 @@ public struct R22DisableReport: Equatable, Sendable { if plan.isEmpty && !planBuilt { planBuilt = true guard keys.contains(R22DisableReport.probeKey) else { - // Defensive: a caller that hands over a key list without the probe key gets a plain - // sequential run rather than a silently skipped gate. - plan = keys.map { Step(opcode: FeatureFlagWriteGate.setFeatureFlagValueCmd, key: $0, stage: .clearWrite) } - + keys.map { Step(opcode: FeatureFlagWriteGate.getFeatureFlagValueCmd, key: $0, stage: .verifyRead) } - probePassed = true - return nextStep() + // FAIL CLOSED. This used to plan a blanket sequential run and set `probePassed = true`, + // which defeated the entire point of the design: the safety argument for writing an + // INFERRED value to sixteen persistent flags is that one flag is written and read back + // first, and a key list without the probe key cannot make that argument. Writing all + // sixteen unprobed is the one outcome the staging exists to prevent, and `probePassed` + // recorded a probe that never ran — so a report could claim a passed gate on a run that + // never had one. Unreachable from either shipped call site (both use the no-arg init), but + // fail-open on a write path is not a defensible default whatever the current call sites do. + stopped = true + for key in keys { outcomes[key] = .skipped } + verdict = .probeUnavailable + trace.append("REFUSED: the key list does not contain the probe key " + + "\(R22DisableReport.probeKey), so '0' cannot be tested on one flag before the " + + "other \(max(0, keys.count - 1)) are written. Nothing was sent.") + return nil } plan = [ Step(opcode: FeatureFlagWriteGate.setFeatureFlagValueCmd, @@ -438,6 +489,7 @@ public struct R22DisableReport: Equatable, Sendable { case .partial: return "\(cleared) of \(keys.count) R22 flags cleared — the rest did not take." case .offValueRejected: return "The strap refused '0' for \(R22DisableReport.probeKey); the other flags were left alone." case .probeInconclusive: return "No usable read-back for \(R22DisableReport.probeKey) — nothing further was written." + case .probeUnavailable: return "Refused to run: \(R22DisableReport.probeKey) is not in the key list, so the off value could not be probed first. Nothing was sent." case .abandoned: return "Disable run interrupted — \(cleared) of \(keys.count) flags confirmed cleared." } } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/R22DisableTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/R22DisableTests.swift index 171e814265..7ac53b189e 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/R22DisableTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/R22DisableTests.swift @@ -64,75 +64,134 @@ final class R22DisableTests: XCTestCase { // MARK: - The write gate func testGateAdmitsOnlyOpcode120() { - let payload = [0x01] + Whoop5Config.payloadBody(name: "enable_r22_packets", value: 0x30) + let off = [0x01] + Whoop5Config.payloadBody(name: "enable_r22_packets", value: 0x30) + // Derived, never hardcoded: enable_r22_packets' enable value is '2', not '1' — the per-key values + // are exactly what this gate is about, so a literal here would be testing the wrong byte. + let onValue = FeatureFlagWriteGate.enableValue(for: "enable_r22_packets")! + let on = [0x01] + Whoop5Config.payloadBody(name: "enable_r22_packets", value: onValue) for opcode in UInt8.min...UInt8.max { - let admitted = FeatureFlagWriteGate.admitsSend(opcode: opcode, payload: payload, deepDataOptIn: true) - XCTAssertEqual(admitted, opcode == 120, "opcode \(opcode) admission") + XCTAssertEqual(FeatureFlagWriteGate.admitsEnableWrite(opcode: opcode, payload: on, deepDataOptIn: true), + opcode == 120, "enable-direction opcode \(opcode) admission") + XCTAssertEqual(FeatureFlagWriteGate.admitsDisableWrite(opcode: opcode, payload: off, disableRunInFlight: true), + opcode == 120, "disable-direction opcode \(opcode) admission") } } func testGateRefusesDeviceConfigWriteVerb() { - let payload = [0x01] + Whoop5Config.payloadBody(name: "enable_r22_packets", value: 0x30) - XCTAssertFalse(FeatureFlagWriteGate.admitsSend(opcode: 119, payload: payload, deepDataOptIn: true), + let off = [0x01] + Whoop5Config.payloadBody(name: "enable_r22_packets", value: 0x30) + // Derived, never hardcoded: enable_r22_packets' enable value is '2', not '1' — the per-key values + // are exactly what this gate is about, so a literal here would be testing the wrong byte. + let onValue = FeatureFlagWriteGate.enableValue(for: "enable_r22_packets")! + let on = [0x01] + Whoop5Config.payloadBody(name: "enable_r22_packets", value: onValue) + XCTAssertFalse(FeatureFlagWriteGate.admitsEnableWrite(opcode: 119, payload: on, deepDataOptIn: true), "119 keeps its own Broadcast-HR clause and must never be reachable from here") + XCTAssertFalse(FeatureFlagWriteGate.admitsDisableWrite(opcode: 119, payload: off, disableRunInFlight: true), + "119 must be unreachable from the disable direction too") } - func testGateRefusesEverythingWithoutTheOptIn() { + func testEnableDirectionRefusesEverythingWithoutTheOptIn() { for flag in Whoop5Config.enableR22Sequence { for value in [flag.value, Whoop5Config.featureFlagOffValue] { let payload = [0x01] + Whoop5Config.payloadBody(name: flag.name, value: value) - XCTAssertFalse(FeatureFlagWriteGate.admitsSend(opcode: 120, payload: payload, deepDataOptIn: false), + XCTAssertFalse(FeatureFlagWriteGate.admitsEnableWrite(opcode: 120, payload: payload, deepDataOptIn: false), "\(flag.name)=\(value) must be refused with the opt-in off") } } } - func testGateAdmitsEveryR22KeyForBothItsEnableValueAndOff() { + func testDisableDirectionRefusesEverythingWithoutARunInFlight() { for flag in Whoop5Config.enableR22Sequence { for value in [flag.value, Whoop5Config.featureFlagOffValue] { let payload = [0x01] + Whoop5Config.payloadBody(name: flag.name, value: value) - XCTAssertTrue(FeatureFlagWriteGate.admitsSend(opcode: 120, payload: payload, deepDataOptIn: true), - "\(flag.name)=\(value) must be admitted") + XCTAssertFalse(FeatureFlagWriteGate.admitsDisableWrite(opcode: 120, payload: payload, disableRunInFlight: false), + "\(flag.name)=\(value) must be refused with no disable run walking its plan") } } } + func testEnableDirectionAdmitsEveryR22KeyAtItsOwnEnableValue() { + for flag in Whoop5Config.enableR22Sequence { + let payload = [0x01] + Whoop5Config.payloadBody(name: flag.name, value: flag.value) + XCTAssertTrue(FeatureFlagWriteGate.admitsEnableWrite(opcode: 120, payload: payload, deepDataOptIn: true), + "\(flag.name)=\(flag.value) must be admitted") + } + } + + func testDisableDirectionAdmitsEveryR22KeyAtTheOffValue() { + for flag in Whoop5Config.enableR22Sequence { + let payload = [0x01] + Whoop5Config.payloadBody(name: flag.name, value: Whoop5Config.featureFlagOffValue) + XCTAssertTrue(FeatureFlagWriteGate.admitsDisableWrite(opcode: 120, payload: payload, disableRunInFlight: true), + "\(flag.name)='0' must be admitted while a run is in flight") + } + } + + /// **The regression test for the defect this split fixes.** The Settings switch is `@AppStorage`-bound, + /// so flipping it OFF writes the pref false BEFORE the confirmation dialog is raised. Every off-value + /// write therefore reaches the send path with `deepDataOptIn == false`, and the single old predicate + /// refused all sixteen — the user tapped "Clear flags on strap" and nothing left the app. The disable + /// direction must be admitted on the run alone, with the opt-in off. + func testOffValueWritesAreAdmittedWithTheOptInOffWhileARunIsInFlight() { + for flag in Whoop5Config.enableR22Sequence { + let payload = [0x01] + Whoop5Config.payloadBody(name: flag.name, value: Whoop5Config.featureFlagOffValue) + XCTAssertTrue(FeatureFlagWriteGate.admitsDisableWrite(opcode: 120, payload: payload, disableRunInFlight: true), + "\(flag.name)='0' must be admitted by the run gate — the opt-in is already false here") + XCTAssertFalse(FeatureFlagWriteGate.admitsEnableWrite(opcode: 120, payload: payload, deepDataOptIn: false), + "and the enable direction must not be what admits it") + } + } + + /// The two directions are disjoint on value, which is what makes "an off value on the wire means a + /// disable run is in flight" an exact invariant rather than an approximate one. + func testTheTwoDirectionsAreDisjointOnValue() { + for flag in Whoop5Config.enableR22Sequence { + let on = [0x01] + Whoop5Config.payloadBody(name: flag.name, value: flag.value) + let off = [0x01] + Whoop5Config.payloadBody(name: flag.name, value: Whoop5Config.featureFlagOffValue) + XCTAssertFalse(FeatureFlagWriteGate.admitsEnableWrite(opcode: 120, payload: off, deepDataOptIn: true), + "\(flag.name): the enable direction must not admit the off value") + XCTAssertFalse(FeatureFlagWriteGate.admitsDisableWrite(opcode: 120, payload: on, disableRunInFlight: true), + "\(flag.name): the disable direction must not admit the enable value") + } + } + /// The tightening this gate exists for: before it, opcode 120 was admitted on the opt-in alone, so ANY - /// feature-flag key with ANY value could travel. Both halves of that are now closed. + /// feature-flag key with ANY value could travel. Both halves of that are closed, in both directions. func testGateRefusesUnknownKeysAndUnknownValues() { for key in ["general_ab_test", "enable_pdaf_walk_det", "enable_maverick_model", "enable_r22_v7_packets"] { - let payload = [0x01] + Whoop5Config.payloadBody(name: key, value: 0x30) - XCTAssertFalse(FeatureFlagWriteGate.admitsSend(opcode: 120, payload: payload, deepDataOptIn: true), + let off = [0x01] + Whoop5Config.payloadBody(name: key, value: 0x30) + let on = [0x01] + Whoop5Config.payloadBody(name: key, value: 0x31) + XCTAssertFalse(FeatureFlagWriteGate.admitsDisableWrite(opcode: 120, payload: off, disableRunInFlight: true), + "\(key) is not an R22 key and must be refused even at the off value") + XCTAssertFalse(FeatureFlagWriteGate.admitsEnableWrite(opcode: 120, payload: on, deepDataOptIn: true), "\(key) is not an R22 key and must be refused") } for value: UInt8 in [0x00, 0x29, 0x33, 0x39, 0x41, 0xFF] { let payload = [0x01] + Whoop5Config.payloadBody(name: "enable_r22_packets", value: value) - XCTAssertFalse(FeatureFlagWriteGate.admitsSend(opcode: 120, payload: payload, deepDataOptIn: true), - "value 0x\(String(format: "%02x", value)) is neither the enable nor the off value") + XCTAssertFalse(FeatureFlagWriteGate.admitsEnableWrite(opcode: 120, payload: payload, deepDataOptIn: true), + "value 0x\(String(format: "%02x", value)) is not the enable value") + XCTAssertFalse(FeatureFlagWriteGate.admitsDisableWrite(opcode: 120, payload: payload, disableRunInFlight: true), + "value 0x\(String(format: "%02x", value)) is not the off value") } // enable_r22_v4_packets' enable value is '1', so '2' must be refused for THAT key specifically. let wrongForV4 = [0x01] + Whoop5Config.payloadBody(name: "enable_r22_v4_packets", value: 0x32) - XCTAssertFalse(FeatureFlagWriteGate.admitsSend(opcode: 120, payload: wrongForV4, deepDataOptIn: true), + XCTAssertFalse(FeatureFlagWriteGate.admitsEnableWrite(opcode: 120, payload: wrongForV4, deepDataOptIn: true), "the admitted value is per-key, not a shared pair") } func testGateRefusesMalformedBodies() { let good = Whoop5Config.payloadBody(name: "enable_r22_packets", value: 0x30) - // Wrong inner b3 byte. - XCTAssertFalse(FeatureFlagWriteGate.admitsSend(opcode: 120, payload: [0x02] + good, deepDataOptIn: true)) - // Truncated. - XCTAssertFalse(FeatureFlagWriteGate.admitsSend(opcode: 120, payload: [0x01] + good.dropLast(1), deepDataOptIn: true)) - // Empty. - XCTAssertFalse(FeatureFlagWriteGate.admitsSend(opcode: 120, payload: [], deepDataOptIn: true)) - // Non-NUL padding after the name. + func refusedBothWays(_ payload: [UInt8], _ why: String) { + XCTAssertFalse(FeatureFlagWriteGate.admitsEnableWrite(opcode: 120, payload: payload, deepDataOptIn: true), why) + XCTAssertFalse(FeatureFlagWriteGate.admitsDisableWrite(opcode: 120, payload: payload, disableRunInFlight: true), why) + } + refusedBothWays([0x02] + good, "wrong inner b3 byte") + refusedBothWays([0x01] + good.dropLast(1), "truncated") + refusedBothWays([], "empty") var dirty = good dirty[25] = 0x41 - XCTAssertFalse(FeatureFlagWriteGate.admitsSend(opcode: 120, payload: [0x01] + dirty, deepDataOptIn: true), - "a name field whose padding is not NUL is not a name field") - // Binary in the name field. + refusedBothWays([0x01] + dirty, "a name field whose padding is not NUL is not a name field") var binary = good binary[3] = 0x01 - XCTAssertFalse(FeatureFlagWriteGate.admitsSend(opcode: 120, payload: [0x01] + binary, deepDataOptIn: true)) + refusedBothWays([0x01] + binary, "binary in the name field") } func testKeyAndValueRoundTripsEveryR22Key() { @@ -317,4 +376,39 @@ final class R22DisableTests: XCTestCase { XCTAssertEqual(R22DisableReport.probeKey, "enable_sig12") XCTAssertTrue(Whoop5Config.enableR22Sequence.contains { $0.name == R22DisableReport.probeKey }) } + + // MARK: - Fail-closed when the probe key is absent + + /// A key list without the probe key used to plan a blanket sixteen-write and set `probePassed = true`. + /// That is fail-open on the one write path whose entire safety argument is the probe: it would write an + /// INFERRED value to every persistent flag without ever testing it on one, and record a passed gate for + /// a probe that never ran. It must refuse instead. + func testAKeyListWithoutTheProbeKeyRefusesToWriteAnything() { + var r = R22DisableReport(keys: ["enable_r22_packets", "enable_r22_v4_packets", "enable_r22_v5_packets"]) + XCTAssertNil(r.nextStep(), "no step may be planned without the probe key") + XCTAssertFalse(r.probePassed, "probePassed must not record a probe that never ran") + XCTAssertEqual(r.verdict, .probeUnavailable) + for key in r.keys { + XCTAssertEqual(r.outcomes[key], .skipped, "\(key) must be reported as skipped, not written") + } + XCTAssertTrue(r.render().contains("REFUSED"), "the report must say nothing was sent") + // And it stays refused: a second call must not fall through to a plan either. + XCTAssertNil(r.nextStep()) + } + + /// An empty key list is the degenerate case of the same thing. + func testAnEmptyKeyListRefusesToWriteAnything() { + var r = R22DisableReport(keys: []) + XCTAssertNil(r.nextStep()) + XCTAssertFalse(r.probePassed) + XCTAssertEqual(r.verdict, .probeUnavailable) + } + + /// The shipped call sites are unaffected: the default key list contains the probe key, so the staged + /// run still plans its probe pair first. + func testTheDefaultKeyListStillPlansTheProbe() { + var r = R22DisableReport() + XCTAssertEqual(r.nextStep()?.stage, .probeWrite) + XCTAssertNotEqual(r.verdict, .probeUnavailable) + } } diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 2d0d104f5d..927706e0db 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1530,16 +1530,26 @@ public final class BLEManager: NSObject, ObservableObject { || (DeviceConfigReadProbe.isReadOnlyOpcode(command.rawValue) && deviceConfigReport != nil) || command == .sendHistoricalData || command == .historicalDataResult || command == .setClock || command == .getClock - // SET_CONFIG / SET_FF_VALUE (120) — the R22 deep-stream unlock AND its undo. Allowed only - // while the deep-data experiment is opted in, and now additionally only for a KEY and a - // VALUE the gate recognises: one of the sixteen R22 flags, carrying either that key's own - // enable value or the documented off value. The clause this replaces was opcode-only, so - // it admitted ANY feature-flag key with ANY value for as long as the opt-in happened to be - // on — the same weakness #907 closed on opcode 119. Driven only by enableWhoop5DeepData() - // and disableWhoop5DeepData(). (#174) - || FeatureFlagWriteGate.admitsSend(opcode: command.rawValue, - payload: payload, - deepDataOptIn: PuffinExperiment.deepDataEnabled) + // SET_CONFIG / SET_FF_VALUE (120), ENABLE direction — the R22 deep-stream unlock. Allowed + // only while the deep-data experiment is opted in, and only for a KEY and a VALUE the gate + // recognises: one of the sixteen R22 flags carrying that key's own enable value. The clause + // this replaces was opcode-only, so it admitted ANY feature-flag key with ANY value for as + // long as the opt-in happened to be on — the same weakness #907 closed on opcode 119. + // Driven only by enableWhoop5DeepData(). (#174) + || FeatureFlagWriteGate.admitsEnableWrite(opcode: command.rawValue, + payload: payload, + deepDataOptIn: PuffinExperiment.deepDataEnabled) + // SET_FF_VALUE (120), DISABLE direction — the undo. Gated on a disable run being in flight + // rather than on the opt-in, exactly like the 128 read-back clause below, and restricted to + // `featureFlagOffValue`. The opt-in CANNOT gate this: `deepDataEnabled` is @AppStorage bound + // straight to the Settings switch, so it is already false by the time the user confirms the + // undo the switch itself offered — gating on it made the whole toggle-off path dead (every + // write refused here, and the run's own guard returning early). Gating on the run is also + // narrower the other way: with the opt-in merely left on, no off value reaches the wire + // unless a run is genuinely walking its plan. Driven only by disableWhoop5DeepData(). (#174) + || FeatureFlagWriteGate.admitsDisableWrite(opcode: command.rawValue, + payload: payload, + disableRunInFlight: r22DisableRun != nil) // GET_FF_VALUE (128) as the disable run's mandatory READ-BACK. Gated on a disable run being // in flight, exactly like the read probes' 121/128 clause above, so a default install can // never form these bytes. The write ack is not trusted; this is what proves the clear. @@ -2464,11 +2474,14 @@ public final class BLEManager: NSObject, ObservableObject { guard selectedModel.deviceFamily == .whoop5 else { log("Deep-data disable: needs a WHOOP 5.0/MG strap selected — ignored."); return } - guard PuffinExperiment.deepDataEnabled else { - // The send allowlist keys off this same pref, so the writes would be dropped anyway. Fail with a - // message that says what to do rather than letting sixteen frames vanish silently. - log("Deep-data disable: the deep-data opt-in is off, so the writes would be refused by the send allowlist. Turn the toggle back on, tap the disable button, then turn it off — ignored."); return - } + // NOTE there is deliberately NO `PuffinExperiment.deepDataEnabled` guard here. There was one, and it + // made this entire function unreachable from the control users actually use: the Settings switch is + // @AppStorage-bound, so flipping it OFF writes the pref false immediately, THEN raises the "Clear the + // R22 flags on your strap?" dialog. By the time the user taps "Clear flags on strap" the pref is + // already false, so the guard returned at the top and nothing happened — the same shape of defect + // this PR exists to fix (UI promising an undo that never runs). The send allowlist now gates the + // off-value writes on `r22DisableRun != nil` instead, which is the state that is actually about this + // operation. (#174) guard state.connected, state.encryptedBond else { log("Deep-data disable: needs the full encrypted bond, not the live-HR-only link. Close the official WHOOP app, put the strap in pairing mode, and bond it to NOOP first — ignored."); return } @@ -2496,9 +2509,11 @@ public final class BLEManager: NSObject, ObservableObject { if step.isWrite { payload = [0x01] + Whoop5Config.payloadBody(name: step.key, value: Whoop5Config.featureFlagOffValue) // Fail closed: the same predicate the send path consults, asked here too, so a future edit that - // widened the plan cannot put an unrecognised key or value on the wire. - guard FeatureFlagWriteGate.admitsSend(opcode: step.opcode, payload: payload, - deepDataOptIn: PuffinExperiment.deepDataEnabled) else { + // widened the plan cannot put an unrecognised key or value on the wire. `disableRunInFlight` is + // true by construction here (this is only called from inside a run) — it is passed rather than + // hardcoded so this and the send path evaluate the identical predicate. + guard FeatureFlagWriteGate.admitsDisableWrite(opcode: step.opcode, payload: payload, + disableRunInFlight: r22DisableRun != nil) else { log("Deep-data disable: refusing to write \(step.key) — not admitted by the feature-flag gate") finishR22Disable() return diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 4c1a96db96..b80f1e596c 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -1537,23 +1537,31 @@ struct SettingsView: View { .foregroundStyle(StrandPalette.textTertiary) } - // #174: the disable run's per-key result. Shown verbatim because the interesting part is - // the read-back table, not a green tick — a write that acked SUCCESS but did not move - // the stored value renders here as "unchanged", which is the case worth seeing. - if let report = live.r22DisableReport { - if report == BLEManager.deviceConfigProbeWaiting { - Label("Clearing R22 flags and reading each one back\u{2026}", systemImage: "ellipsis") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - } else { - Text(report) - .font(StrandFont.caption.monospaced()) - .foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) - NoopButton("Dismiss disable report", systemImage: "xmark", kind: .secondary) { - model.ble.clearR22DisableReport() - } + } + + // #174: the disable run's per-key result. Shown verbatim because the interesting part is + // the read-back table, not a green tick — a write that acked SUCCESS but did not move + // the stored value renders here as "unchanged", which is the case worth seeing. + // + // OUTSIDE the `if deepDataEnabled` block on purpose. The commonest way to reach a disable + // run is flipping the switch OFF and confirming, which means the pref is already false while + // the run is walking its plan — so nesting this inside that block hid the progress line and + // the whole read-back table for exactly the run a user is most likely to start. The report + // is about what is on the STRAP, which outlives the app's opt-in: it stays legible (and + // dismissable) whatever the switch says. + if let report = live.r22DisableReport { + if report == BLEManager.deviceConfigProbeWaiting { + Label("Clearing R22 flags and reading each one back\u{2026}", systemImage: "ellipsis") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } else { + Text(report) + .font(StrandFont.caption.monospaced()) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + NoopButton("Dismiss disable report", systemImage: "xmark", kind: .secondary) { + model.ble.clearR22DisableReport() } } } diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index b18f889ade..d87153f0ed 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -2878,16 +2878,24 @@ class WhoopBleClient( // keep their own separate opt-in clauses below and are never sent from this path. Driven // only by probeDeviceConfigValues() (user-initiated, Test Centre gated). !(DeviceConfigReadProbe.isReadOnlyOpcode(cmd.rawValue) && deviceConfigReport != null) && - // SET_CONFIG / SET_FF_VALUE (120) — the R22 deep-stream unlock AND its undo. Allowed only - // while the deep-data experiment is opted in, and now additionally only for a KEY and a - // VALUE the gate recognises: one of the sixteen R22 flags carrying either that key's own - // enable value or the documented off value. The clause this replaces was opcode-only, so - // it admitted ANY feature-flag key with ANY value for as long as the opt-in happened to be - // on — the same weakness #907 closed on opcode 119. Driven only by enableWhoop5DeepData() - // and disableWhoop5DeepData(). (#174) - !FeatureFlagWriteGate.admitsSend( + // SET_CONFIG / SET_FF_VALUE (120), ENABLE direction — the R22 deep-stream unlock. Allowed + // only while the deep-data experiment is opted in, and only for a KEY and a VALUE the gate + // recognises: one of the sixteen R22 flags carrying that key's own enable value. The clause + // this replaces was opcode-only, so it admitted ANY feature-flag key with ANY value for as + // long as the opt-in happened to be on — the same weakness #907 closed on opcode 119. + // Driven only by enableWhoop5DeepData(). (#174) + !FeatureFlagWriteGate.admitsEnableWrite( cmd.rawValue, payload, puffinExperiment.isDeepDataEnabled, ) && + // SET_FF_VALUE (120), DISABLE direction — the undo. Gated on a disable run being in flight + // rather than on the opt-in, exactly like the 128 read-back clause below, and restricted to + // FEATURE_FLAG_OFF_VALUE. The opt-in CANNOT gate this: the Settings switch writes the pref + // false before it raises the confirmation dialog, so it is already false by the time the + // user confirms the undo the switch itself offered — gating on it made the whole toggle-off + // path dead. Driven only by disableWhoop5DeepData(). (#174) + !FeatureFlagWriteGate.admitsDisableWrite( + cmd.rawValue, payload, r22DisableRun != null, + ) && // GET_FF_VALUE (128) as the disable run's mandatory READ-BACK. Gated on a disable run being // in flight, exactly like the read probes' 121/128 clause above, so a default install can // never form these bytes. The write ack is not trusted; this is what proves the clear. @@ -5727,11 +5735,13 @@ class WhoopBleClient( if (connectedFamily != DeviceFamily.WHOOP5) { log("Deep-data disable: needs a WHOOP 5.0/MG strap — ignored."); return } - if (!puffinExperiment.isDeepDataEnabled) { - // The send allowlist keys off this same pref, so the writes would be dropped anyway. Fail with - // a message that says what to do rather than letting sixteen frames vanish silently. - log("Deep-data disable: the deep-data opt-in is off, so the writes would be refused by the send allowlist. Turn the toggle back on, tap the disable button, then turn it off — ignored."); return - } + // NOTE there is deliberately NO isDeepDataEnabled guard here. There was one, and it made this whole + // function unreachable from the control users actually use: the Settings switch writes the pref + // false the moment it is flipped OFF, THEN raises the "Clear the R22 flags on your strap?" dialog. + // By the time the user taps "Clear flags on strap" the pref is already false, so the guard returned + // at the top and nothing happened — the same shape of defect this change exists to fix (UI + // promising an undo that never runs). The send allowlist now gates the off-value writes on + // r22DisableRun != null instead, which is the state that is actually about this operation. (#174) val s = _state.value if (!s.connected || !s.encryptedBond) { log("Deep-data disable: needs the full encrypted bond, not the live-HR-only link. Close the official WHOOP app, put the strap in pairing mode, and bond it to NOOP first — ignored."); return @@ -5763,8 +5773,10 @@ class WhoopBleClient( payload = byteArrayOf(0x01) + Whoop5Config.payloadBody(step.key, Whoop5Config.FEATURE_FLAG_OFF_VALUE) // Fail closed: the same predicate the send path consults, asked here too, so a future edit that - // widened the plan cannot put an unrecognised key or value on the wire. - if (!FeatureFlagWriteGate.admitsSend(step.opcode, payload, puffinExperiment.isDeepDataEnabled)) { + // widened the plan cannot put an unrecognised key or value on the wire. `r22DisableRun != null` + // is true by construction here (this is only called from inside a run) — it is passed rather + // than hardcoded so this and the send path evaluate the identical predicate. + if (!FeatureFlagWriteGate.admitsDisableWrite(step.opcode, payload, r22DisableRun != null)) { log("Deep-data disable: refusing to write ${step.key} — not admitted by the feature-flag gate") finishR22Disable(); return } diff --git a/android/app/src/main/java/com/noop/protocol/R22Disable.kt b/android/app/src/main/java/com/noop/protocol/R22Disable.kt index 75757afd9e..7018f183c3 100644 --- a/android/app/src/main/java/com/noop/protocol/R22Disable.kt +++ b/android/app/src/main/java/com/noop/protocol/R22Disable.kt @@ -12,7 +12,21 @@ package com.noop.protocol * discipline as `DeviceConfigReadProbe.isReadOnlyOpcode`: one pure predicate the send path itself * consults, so a unit test proving the predicate rejects something proves it about the real wire path. * - * Pure: no Bluetooth, no I/O, no preferences. The app layer supplies the opt-in boolean. + * TWO predicates, not one, because the enable and disable directions have different gates as well as + * different values: + * + * - [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, [Whoop5Config.FEATURE_FLAG_OFF_VALUE] only, while a + * disable run is in flight. + * + * The disable direction CANNOT be gated on the opt-in: the Settings switch writes the preference false + * before the confirmation dialog is raised, so the pref is already false by the time the user confirms the + * undo — gating on it made the toggle-off path dead. The run is the gate that is actually about this + * operation, and it is narrower the other way too: a merely-left-on opt-in sends no off value at all. + * Splitting them makes the invariant exact — an off value on the wire means a run is in flight. + * + * Pure: no Bluetooth, no I/O, no preferences. The app layer supplies the gate booleans. */ object FeatureFlagWriteGate { @@ -39,10 +53,17 @@ object FeatureFlagWriteGate { fun enableValue(key: String): Int? = Whoop5Config.enableR22Sequence.firstOrNull { it.name == key }?.value - /** Whether [value] is one NOOP may write for [key]: that key's own enable value, or the off value. */ - fun isWritableValue(value: Int, key: String): Boolean { + /** Whether [value] is the ENABLE value for [key] — the only value the enable direction may write. */ + fun isEnableValue(value: Int, key: String): Boolean { val on = enableValue(key) ?: return false - return value == on || value == Whoop5Config.FEATURE_FLAG_OFF_VALUE + return value == on + } + + /** Whether [key] is one of the sixteen AND [value] is the off value — the only pair the disable + * direction may write. The key check is what stops the off value reaching an arbitrary flag. */ + fun isOffValue(value: Int, key: String): Boolean { + enableValue(key) ?: return false + return value == Whoop5Config.FEATURE_FLAG_OFF_VALUE } /** One parsed SET_FF_VALUE body. */ @@ -75,15 +96,31 @@ object FeatureFlagWriteGate { } /** - * **The send allowlist itself.** True only for SET_FF_VALUE(120) carrying a well-formed body whose - * key is one of the sixteen and whose value is that key's enable value or the off value. Every other - * opcode is false — explicitly including SET_DEVICE_CONFIG_VALUE(119), which keeps its own clause. + * **The enable direction's send allowlist.** True only for SET_FF_VALUE(120) carrying a well-formed + * body whose key is one of the sixteen and whose value is that key's own enable value, while the + * deep-data opt-in is on. Every other opcode is false — explicitly including + * SET_DEVICE_CONFIG_VALUE(119), which keeps its own clause. The off value is NOT admitted here. */ - fun admitsSend(opcode: Int, payload: ByteArray, deepDataOptIn: Boolean): Boolean { + fun admitsEnableWrite(opcode: Int, payload: ByteArray, deepDataOptIn: Boolean): Boolean { if (!deepDataOptIn) return false if (opcode != SET_FEATURE_FLAG_VALUE_CMD) return false val kv = keyAndValue(payload) ?: return false - return isWritableValue(kv.value, kv.key) + return isEnableValue(kv.value, kv.key) + } + + /** + * **The disable direction's send allowlist.** True only for SET_FF_VALUE(120) carrying a well-formed + * body whose key is one of the sixteen and whose value is [Whoop5Config.FEATURE_FLAG_OFF_VALUE], while + * a disable run is actually in flight. + * + * [disableRunInFlight] is `r22DisableRun != null` at the call site — deliberately NOT the deep-data + * preference, which is already false by the time a user confirms the undo the switch itself offered. + */ + fun admitsDisableWrite(opcode: Int, payload: ByteArray, disableRunInFlight: Boolean): Boolean { + if (!disableRunInFlight) return false + if (opcode != SET_FEATURE_FLAG_VALUE_CMD) return false + val kv = keyAndValue(payload) ?: return false + return isOffValue(kv.value, kv.key) } /** The read verb the post-write verification is allowed to send, and only that one. */ @@ -182,6 +219,10 @@ class R22DisableReport( /** The probe read-back produced no usable answer. Distinct from a rejection on purpose: nothing * was learned about the off value, so it must not be reported as evidence against it. */ PROBE_INCONCLUSIVE("probeInconclusive"), + /** The run refused to start because the key list does not contain PROBE_KEY, so the off value + * could not be tested on one flag first. Nothing was sent. Distinct from PROBE_INCONCLUSIVE: + * there the probe ran and answered nothing, here it could not run at all. */ + PROBE_UNAVAILABLE("probeUnavailable"), ABANDONED("abandoned"), } @@ -228,12 +269,22 @@ class R22DisableReport( if (plan.isEmpty() && !planBuilt) { planBuilt = true if (!keys.contains(PROBE_KEY)) { - // Defensive: a caller that hands over a key list without the probe key gets a plain - // sequential run rather than a silently skipped gate. - keys.forEach { plan.add(Step(FeatureFlagWriteGate.SET_FEATURE_FLAG_VALUE_CMD, it, Stage.CLEAR_WRITE)) } - keys.forEach { plan.add(Step(FeatureFlagWriteGate.GET_FEATURE_FLAG_VALUE_CMD, it, Stage.VERIFY_READ)) } - probePassed = true - return nextStep() + // FAIL CLOSED. This used to plan a blanket sequential run and set probePassed = true, which + // defeated the point of the design: the safety argument for writing an INFERRED value to + // sixteen persistent flags is that one flag is written and read back first, and a key list + // without the probe key cannot make that argument. Writing all sixteen unprobed is the one + // outcome the staging exists to prevent, and probePassed recorded a probe that never ran. + // Unreachable from either shipped call site (both use the default key list), but fail-open + // on a write path is not a defensible default whatever the current call sites do. + stopped = true + keys.forEach { outcomes[it] = KeyOutcome.SKIPPED } + verdict = Verdict.PROBE_UNAVAILABLE + trace.add( + "REFUSED: the key list does not contain the probe key $PROBE_KEY, so '0' cannot be " + + "tested on one flag before the other ${maxOf(0, keys.size - 1)} are written. " + + "Nothing was sent." + ) + return null } plan.add(Step(FeatureFlagWriteGate.SET_FEATURE_FLAG_VALUE_CMD, PROBE_KEY, Stage.PROBE_WRITE)) plan.add(Step(FeatureFlagWriteGate.GET_FEATURE_FLAG_VALUE_CMD, PROBE_KEY, Stage.PROBE_READ)) @@ -362,6 +413,7 @@ class R22DisableReport( Verdict.PARTIAL -> "$cleared of ${keys.size} R22 flags cleared — the rest did not take." Verdict.OFF_VALUE_REJECTED -> "The strap refused '0' for $PROBE_KEY; the other flags were left alone." Verdict.PROBE_INCONCLUSIVE -> "No usable read-back for $PROBE_KEY — nothing further was written." + Verdict.PROBE_UNAVAILABLE -> "Refused to run: $PROBE_KEY is not in the key list, so the off value could not be probed first. Nothing was sent." Verdict.ABANDONED -> "Disable run interrupted — $cleared of ${keys.size} flags confirmed cleared." } } diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index c28c37fddd..98629c629a 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -1969,29 +1969,36 @@ fun SettingsScreen( ) } - // #174: the disable run's per-key result. Shown verbatim because the interesting part - // is the read-back table, not a green tick — a write that acked SUCCESS but did not - // move the stored value renders here as "unchanged", which is the case worth seeing. - val disableReport = r22DisableReport - if (disableReport != null) { - if (disableReport == WhoopBleClient.WAITING_DEVICE_CONFIG_PROBE) { - Text( - uiString(R.string.l10n_settings_screen_r22disable_running), - style = NoopType.caption, - color = Palette.textSecondary, - ) - } else { - Text( - disableReport, - style = NoopType.caption.copy(fontFamily = FontFamily.Monospace), - color = Palette.textSecondary, - ) - NoopButton( - text = uiString(R.string.l10n_settings_screen_r22disable_dismiss), - kind = NoopButtonKind.Secondary, - onClick = { vm.ble.clearR22DisableReport() }, - ) - } + } + + // #174: the disable run's per-key result. Shown verbatim because the interesting part + // is the read-back table, not a green tick — a write that acked SUCCESS but did not + // move the stored value renders here as "unchanged", which is the case worth seeing. + // + // OUTSIDE the `if (deepData)` block on purpose. The commonest way to reach a disable run is + // flipping the switch OFF and confirming, which means the pref is already false while the + // run is walking its plan — so nesting this inside that block hid the progress line and the + // whole read-back table for exactly the run a user is most likely to start. The report is + // about what is on the STRAP, which outlives the app's opt-in. + val disableReport = r22DisableReport + if (disableReport != null) { + if (disableReport == WhoopBleClient.WAITING_DEVICE_CONFIG_PROBE) { + Text( + uiString(R.string.l10n_settings_screen_r22disable_running), + style = NoopType.caption, + color = Palette.textSecondary, + ) + } else { + Text( + disableReport, + style = NoopType.caption.copy(fontFamily = FontFamily.Monospace), + color = Palette.textSecondary, + ) + NoopButton( + text = uiString(R.string.l10n_settings_screen_r22disable_dismiss), + kind = NoopButtonKind.Secondary, + onClick = { vm.ble.clearR22DisableReport() }, + ) } } diff --git a/android/app/src/test/java/com/noop/protocol/R22DisableTest.kt b/android/app/src/test/java/com/noop/protocol/R22DisableTest.kt index c0d7e2f46d..7c09701410 100644 --- a/android/app/src/test/java/com/noop/protocol/R22DisableTest.kt +++ b/android/app/src/test/java/com/noop/protocol/R22DisableTest.kt @@ -2,6 +2,7 @@ package com.noop.protocol import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -75,87 +76,173 @@ class R22DisableTest { @Test fun gateAdmitsOnlyOpcode120() { - val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody("enable_r22_packets", 0x30) + val off = byteArrayOf(0x01) + Whoop5Config.payloadBody("enable_r22_packets", 0x30) + // Derived, never hardcoded: enable_r22_packets' enable value is '2', not '1' — the per-key values + // are exactly what this gate is about, so a literal here would be testing the wrong byte. + val onValue = FeatureFlagWriteGate.enableValue("enable_r22_packets")!! + val on = byteArrayOf(0x01) + Whoop5Config.payloadBody("enable_r22_packets", onValue) for (opcode in 0..255) { assertEquals( - "opcode $opcode admission", + "enable-direction opcode $opcode admission", opcode == 120, - FeatureFlagWriteGate.admitsSend(opcode, payload, deepDataOptIn = true), + FeatureFlagWriteGate.admitsEnableWrite(opcode, on, deepDataOptIn = true), + ) + assertEquals( + "disable-direction opcode $opcode admission", + opcode == 120, + FeatureFlagWriteGate.admitsDisableWrite(opcode, off, disableRunInFlight = true), ) } } @Test fun gateRefusesDeviceConfigWriteVerb() { - val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody("enable_r22_packets", 0x30) + val off = byteArrayOf(0x01) + Whoop5Config.payloadBody("enable_r22_packets", 0x30) + val onValue = FeatureFlagWriteGate.enableValue("enable_r22_packets")!! + val on = byteArrayOf(0x01) + Whoop5Config.payloadBody("enable_r22_packets", onValue) assertFalse( "119 keeps its own Broadcast-HR clause and must never be reachable from here", - FeatureFlagWriteGate.admitsSend(119, payload, deepDataOptIn = true), + FeatureFlagWriteGate.admitsEnableWrite(119, on, deepDataOptIn = true), + ) + assertFalse( + "119 must be unreachable from the disable direction too", + FeatureFlagWriteGate.admitsDisableWrite(119, off, disableRunInFlight = true), ) } @Test - fun gateRefusesEverythingWithoutTheOptIn() { + fun enableDirectionRefusesEverythingWithoutTheOptIn() { for (flag in Whoop5Config.enableR22Sequence) { for (value in listOf(flag.value, Whoop5Config.FEATURE_FLAG_OFF_VALUE)) { val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody(flag.name, value) assertFalse( "${flag.name}=$value must be refused with the opt-in off", - FeatureFlagWriteGate.admitsSend(120, payload, deepDataOptIn = false), + FeatureFlagWriteGate.admitsEnableWrite(120, payload, deepDataOptIn = false), ) } } } @Test - fun gateAdmitsEveryR22KeyForBothItsEnableValueAndOff() { + fun disableDirectionRefusesEverythingWithoutARunInFlight() { for (flag in Whoop5Config.enableR22Sequence) { for (value in listOf(flag.value, Whoop5Config.FEATURE_FLAG_OFF_VALUE)) { val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody(flag.name, value) - assertTrue( - "${flag.name}=$value must be admitted", - FeatureFlagWriteGate.admitsSend(120, payload, deepDataOptIn = true), + assertFalse( + "${flag.name}=$value must be refused with no disable run walking its plan", + FeatureFlagWriteGate.admitsDisableWrite(120, payload, disableRunInFlight = false), ) } } } + @Test + fun enableDirectionAdmitsEveryR22KeyAtItsOwnEnableValue() { + for (flag in Whoop5Config.enableR22Sequence) { + val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody(flag.name, flag.value) + assertTrue( + "${flag.name}=${flag.value} must be admitted", + FeatureFlagWriteGate.admitsEnableWrite(120, payload, deepDataOptIn = true), + ) + } + } + + @Test + fun disableDirectionAdmitsEveryR22KeyAtTheOffValue() { + for (flag in Whoop5Config.enableR22Sequence) { + val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody(flag.name, Whoop5Config.FEATURE_FLAG_OFF_VALUE) + assertTrue( + "${flag.name}='0' must be admitted while a run is in flight", + FeatureFlagWriteGate.admitsDisableWrite(120, payload, disableRunInFlight = true), + ) + } + } + + /** + * **The regression test for the defect this split fixes.** The Settings switch writes the preference + * false BEFORE it raises the confirmation dialog, so every off-value write reaches the send path with + * `deepDataOptIn == false` and the single old predicate refused all sixteen — the user tapped "Clear + * flags on strap" and nothing left the app. The disable direction must be admitted on the run alone. + */ + @Test + fun offValueWritesAreAdmittedWithTheOptInOffWhileARunIsInFlight() { + for (flag in Whoop5Config.enableR22Sequence) { + val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody(flag.name, Whoop5Config.FEATURE_FLAG_OFF_VALUE) + assertTrue( + "${flag.name}='0' must be admitted by the run gate — the opt-in is already false here", + FeatureFlagWriteGate.admitsDisableWrite(120, payload, disableRunInFlight = true), + ) + assertFalse( + "and the enable direction must not be what admits it", + FeatureFlagWriteGate.admitsEnableWrite(120, payload, deepDataOptIn = false), + ) + } + } + + /** The two directions are disjoint on value, which is what makes "an off value on the wire means a + * disable run is in flight" an exact invariant rather than an approximate one. */ + @Test + fun theTwoDirectionsAreDisjointOnValue() { + for (flag in Whoop5Config.enableR22Sequence) { + val on = byteArrayOf(0x01) + Whoop5Config.payloadBody(flag.name, flag.value) + val off = byteArrayOf(0x01) + Whoop5Config.payloadBody(flag.name, Whoop5Config.FEATURE_FLAG_OFF_VALUE) + assertFalse( + "${flag.name}: the enable direction must not admit the off value", + FeatureFlagWriteGate.admitsEnableWrite(120, off, deepDataOptIn = true), + ) + assertFalse( + "${flag.name}: the disable direction must not admit the enable value", + FeatureFlagWriteGate.admitsDisableWrite(120, on, disableRunInFlight = true), + ) + } + } + /** The tightening this gate exists for: before it, opcode 120 was admitted on the opt-in alone, so ANY - * feature-flag key with ANY value could travel. Both halves of that are now closed. */ + * feature-flag key with ANY value could travel. Both halves are closed, in both directions. */ @Test fun gateRefusesUnknownKeysAndUnknownValues() { for (key in listOf("general_ab_test", "enable_pdaf_walk_det", "enable_maverick_model", "enable_r22_v7_packets")) { - val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody(key, 0x30) - assertFalse("$key is not an R22 key", FeatureFlagWriteGate.admitsSend(120, payload, deepDataOptIn = true)) + val off = byteArrayOf(0x01) + Whoop5Config.payloadBody(key, 0x30) + val on = byteArrayOf(0x01) + Whoop5Config.payloadBody(key, 0x31) + assertFalse( + "$key is not an R22 key and must be refused even at the off value", + FeatureFlagWriteGate.admitsDisableWrite(120, off, disableRunInFlight = true), + ) + assertFalse("$key is not an R22 key", FeatureFlagWriteGate.admitsEnableWrite(120, on, deepDataOptIn = true)) } for (value in listOf(0x00, 0x29, 0x33, 0x39, 0x41, 0xFF)) { val payload = byteArrayOf(0x01) + Whoop5Config.payloadBody("enable_r22_packets", value) assertFalse( - "value 0x%02x is neither the enable nor the off value".format(value), - FeatureFlagWriteGate.admitsSend(120, payload, deepDataOptIn = true), + "value 0x%02x is not the enable value".format(value), + FeatureFlagWriteGate.admitsEnableWrite(120, payload, deepDataOptIn = true), + ) + assertFalse( + "value 0x%02x is not the off value".format(value), + FeatureFlagWriteGate.admitsDisableWrite(120, payload, disableRunInFlight = true), ) } // enable_r22_v4_packets' enable value is '1', so '2' must be refused for THAT key specifically. val wrongForV4 = byteArrayOf(0x01) + Whoop5Config.payloadBody("enable_r22_v4_packets", 0x32) assertFalse( "the admitted value is per-key, not a shared pair", - FeatureFlagWriteGate.admitsSend(120, wrongForV4, deepDataOptIn = true), + FeatureFlagWriteGate.admitsEnableWrite(120, wrongForV4, deepDataOptIn = true), ) } @Test fun gateRefusesMalformedBodies() { val good = Whoop5Config.payloadBody("enable_r22_packets", 0x30) - assertFalse(FeatureFlagWriteGate.admitsSend(120, byteArrayOf(0x02) + good, deepDataOptIn = true)) - assertFalse(FeatureFlagWriteGate.admitsSend(120, byteArrayOf(0x01) + good.copyOfRange(0, good.size - 1), deepDataOptIn = true)) - assertFalse(FeatureFlagWriteGate.admitsSend(120, ByteArray(0), deepDataOptIn = true)) + fun refusedBothWays(payload: ByteArray, why: String) { + assertFalse(why, FeatureFlagWriteGate.admitsEnableWrite(120, payload, deepDataOptIn = true)) + assertFalse(why, FeatureFlagWriteGate.admitsDisableWrite(120, payload, disableRunInFlight = true)) + } + refusedBothWays(byteArrayOf(0x02) + good, "wrong inner b3 byte") + refusedBothWays(byteArrayOf(0x01) + good.copyOfRange(0, good.size - 1), "truncated") + refusedBothWays(ByteArray(0), "empty") val dirty = good.copyOf(); dirty[25] = 0x41 - assertFalse( - "a name field whose padding is not NUL is not a name field", - FeatureFlagWriteGate.admitsSend(120, byteArrayOf(0x01) + dirty, deepDataOptIn = true), - ) + refusedBothWays(byteArrayOf(0x01) + dirty, "a name field whose padding is not NUL is not a name field") val binary = good.copyOf(); binary[3] = 0x01 - assertFalse(FeatureFlagWriteGate.admitsSend(120, byteArrayOf(0x01) + binary, deepDataOptIn = true)) + refusedBothWays(byteArrayOf(0x01) + binary, "binary in the name field") } @Test @@ -335,6 +422,44 @@ class R22DisableTest { assertTrue(Whoop5Config.enableR22Sequence.any { it.name == R22DisableReport.PROBE_KEY }) } + // Fail-closed when the probe key is absent -------------------------------------------------- + + /** + * A key list without the probe key used to plan a blanket sixteen-write and set probePassed = true. + * That is fail-open on the one write path whose entire safety argument is the probe: it would write an + * INFERRED value to every persistent flag without ever testing it on one, and record a passed gate for + * a probe that never ran. It must refuse instead. + */ + @Test + fun aKeyListWithoutTheProbeKeyRefusesToWriteAnything() { + val r = R22DisableReport(listOf("enable_r22_packets", "enable_r22_v4_packets", "enable_r22_v5_packets")) + assertNull("no step may be planned without the probe key", r.nextStep()) + assertFalse("probePassed must not record a probe that never ran", r.probePassed) + assertEquals(R22DisableReport.Verdict.PROBE_UNAVAILABLE, r.verdict) + for (key in r.keys) { + assertEquals("$key must be reported as skipped, not written", R22DisableReport.KeyOutcome.SKIPPED, r.outcomes[key]) + } + assertTrue("the report must say nothing was sent", r.render().contains("REFUSED")) + assertNull(r.nextStep()) + } + + /** An empty key list is the degenerate case of the same thing. */ + @Test + fun anEmptyKeyListRefusesToWriteAnything() { + val r = R22DisableReport(emptyList()) + assertNull(r.nextStep()) + assertFalse(r.probePassed) + assertEquals(R22DisableReport.Verdict.PROBE_UNAVAILABLE, r.verdict) + } + + /** The shipped call sites are unaffected: the default key list contains the probe key. */ + @Test + fun theDefaultKeyListStillPlansTheProbe() { + val r = R22DisableReport() + assertEquals(R22DisableReport.Stage.PROBE_WRITE, r.nextStep()?.stage) + assertNotEquals(R22DisableReport.Verdict.PROBE_UNAVAILABLE, r.verdict) + } + /** Cross-platform parity: the Swift and Kotlin reports must render the same verdict labels, or a * shared strap log reads differently on the two platforms. */ @Test @@ -343,6 +468,7 @@ class R22DisableTest { assertEquals("partial", R22DisableReport.Verdict.PARTIAL.label) assertEquals("offValueRejected", R22DisableReport.Verdict.OFF_VALUE_REJECTED.label) assertEquals("probeInconclusive", R22DisableReport.Verdict.PROBE_INCONCLUSIVE.label) + assertEquals("probeUnavailable", R22DisableReport.Verdict.PROBE_UNAVAILABLE.label) assertEquals("abandoned", R22DisableReport.Verdict.ABANDONED.label) assertEquals("cleared", R22DisableReport.KeyOutcome.CLEARED.label) assertEquals("unset", R22DisableReport.KeyOutcome.UNSET.label)