From 776e2605c1c001e697d0f432f4c510a8fd2bb2d2 Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:37:17 -0400 Subject: [PATCH 1/2] Ask the strap to LIST its device-config keys before guessing any: a read-only 115/116 enumeration probe, with a derived key-name sweep as the fallback (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only follow-up to #890. On a WHOOP 5 MG (WS50_r03) both of that PR's read verbs answered, and the reply turned out to be an existence oracle: a key name the firmware has answers result=SUCCESS(1), one it does not answers FAILURE(0). One round-trip is therefore a key-existence test, which makes a finite search over key names possible for the first time. But guessing is the fallback, not the plan. The CommandNumber table in this repo's own whoop_protocol.json names a device-config ENUMERATION pair nothing here has ever sent — 115 START_DEVICE_CONFIG_KEY_EXCHANGE and 116 SEND_NEXT_DEVICE_CONFIG — the structural twin of the 117/118 feature-flag pair #872 built and a strap answered. If they answer, the strap hands over its own device-config key list and no name needs guessing: the candidate sweep is skipped and the report says so. A clean "115/116 are not served" is equally useful, and is what promotes guessing from a shortcut to the only method. 115/116 are decoded by FeatureFlagProbe.parseStart/parseNext with the opcode passed in, on the assumed-symmetric 117/118 record layout — an inference from the naming symmetry in this repo's own table, not an observation. It fails closed, and the walk inherits #874's discipline: the strap's own end marker terminates it, but an entry whose name does not decode is counted and stepped over rather than treated as the end. The plan then reads the values of keys already known to exist, spends two cross-namespace round-trips establishing whether 121 and 128 really serve separate namespaces (if one serves both, everything afterwards goes through it), and only then asks the guessed names. ConfigKeySweep.catalogue is derived, not free-associated: every one of its 54 names is a cross-product of the morphology of the seventeen confirmed keys (enable__packets, make__visible, enable_sig [_during_sleep], whoop__in_, and six more) with the vocabulary the firmware uses about itself — the CommandNumber table's optical (107/108), labrador (124/125/139), research (131/132), afe (61/62) and its revision tokens r7/r10/r11/r20/r21, plus the strap's own console-log subsystem tags. One of those tags does most of the work: "SIGPROC: generated a valid SPO2 during sleep" pairs the firmware's SpO2 computation with the same "during sleep" phrasing the confirmed key enable_sig11_during_sleep uses, which is why the sig number line is the catalogue's largest family. The eight plain-English oxygen names a real MG already answered FAILURE move to retiredKeys — out of the sweep, kept in the file so nobody proposes them again. Read-only by construction: readOnlyOpcodes is {115, 116, 121, 128} and isReadOnlyOpcode is the same predicate the 5/MG send() allowlist consults, admitting them only while a probe is in flight. Tests assert 119, 120 and all 252 other opcodes are rejected. Per-step timeout retires a dead verb after one window; the plan is capped at 128 round-trips; one run is bounded and reports its own tested/untested arithmetic so a grown catalogue truncates visibly. Gated behind Test Centre -> Connection like the #592/#690/#872/#890 probes. No new storage, no migration, no new strings. swift test: 436 passed / 0 failures. Android testFullDebugUnitTest: 3208 tests, 5 skipped, and 1 failure — com.noop.data.DeepCaptureMigrationTest, which fails identically on a clean upstream/main and which nothing here touches. --- .../WhoopProtocol/ConfigKeySweep.swift | 349 +++++++++++ .../WhoopProtocol/DeviceConfigReadProbe.swift | 502 +++++++++++---- .../WhoopProtocol/FeatureFlagProbe.swift | 19 +- .../ConfigKeySweepTests.swift | 254 ++++++++ .../DeviceConfigReadProbeTests.swift | 552 +++++++++++------ Strand/BLE/BLEManager.swift | 90 ++- Strand/BLE/Commands.swift | 17 + .../main/java/com/noop/ble/WhoopBleClient.kt | 94 ++- .../java/com/noop/protocol/ConfigKeySweep.kt | 371 +++++++++++ .../noop/protocol/DeviceConfigReadProbe.kt | 583 ++++++++++++++---- .../src/main/java/com/noop/protocol/Enums.kt | 13 + .../com/noop/protocol/FeatureFlagProbe.kt | 31 +- .../com/noop/protocol/ConfigKeySweepTest.kt | 280 +++++++++ .../protocol/DeviceConfigReadProbeTest.kt | 563 +++++++++++------ docs/PROTOCOL.md | 99 +-- 15 files changed, 3107 insertions(+), 710 deletions(-) create mode 100644 Packages/WhoopProtocol/Sources/WhoopProtocol/ConfigKeySweep.swift create mode 100644 Packages/WhoopProtocol/Tests/WhoopProtocolTests/ConfigKeySweepTests.swift create mode 100644 android/app/src/main/java/com/noop/protocol/ConfigKeySweep.kt create mode 100644 android/app/src/test/java/com/noop/protocol/ConfigKeySweepTest.kt diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/ConfigKeySweep.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/ConfigKeySweep.swift new file mode 100644 index 0000000000..0250c38195 --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/ConfigKeySweep.swift @@ -0,0 +1,349 @@ +import Foundation + +/// #103: the DEVICE-CONFIG ENUMERATION verbs (115/116), the key-existence ORACLE that #890's read verbs +/// turned out to be, and the candidate-name catalogue the sweep falls back to when enumeration is refused. +/// +/// ## 1. Enumerate. Only guess what enumeration cannot reach. +/// +/// This repo's own protocol table (`Resources/whoop_protocol.json`, `CommandNumber`) names two symmetric +/// config namespaces, four verbs each: +/// +/// ``` +/// feature-flag 117 START_FF_KEY_EXCHANGE 118 SEND_NEXT_FF +/// 120 SET_FF_VALUE 128 GET_FF_VALUE +/// device-config 115 START_DEVICE_CONFIG_KEY_EXCHANGE 116 SEND_NEXT_DEVICE_CONFIG +/// 119 SET_DEVICE_CONFIG_VALUE 121 GET_DEVICE_CONFIG_VALUE +/// ``` +/// +/// #872 built the feature-flag enumerate pair (117/118) and a WHOOP 5 MG answered it, listing the sixteen +/// keys in `Whoop5Config.enableR22Sequence`. #890 built both VALUE reads (121/128) and the same strap +/// answered those too. **The device-config enumerate pair — 115/116 — has never been sent by anything +/// here**, and it is the structural twin of the pair that already works. +/// +/// That matters more than any amount of name-guessing: if 115/116 answer, the strap simply hands over its +/// own device-config key list. No dictionary, no morphology, no oracle sweep. So this probe asks first +/// and guesses second, and the candidate catalogue below exists only for the case where it is refused. +/// +/// The 115/116 record layouts are ASSUMED to match their 117/118 twins (`FeatureFlagProbe.parseStart` / +/// `parseNext`, parameterised by opcode). That is an inference from the naming symmetry in this repo's +/// own table, not an observation, and it fails closed: a layout mismatch surfaces as a short record or an +/// implausible count and retires the walk with a named reason rather than inventing key names. +/// +/// ## 2. The oracle +/// +/// On a WHOOP 5 MG (WS50_r03) the 121/128 reads answer differently for a key name the firmware knows and +/// one it does not: +/// +/// | reply | meaning | +/// |---|---| +/// | `result = SUCCESS(1)` | the key EXISTS in that namespace | +/// | `result = FAILURE(0)` | the firmware has no key by that name | +/// +/// One round-trip is therefore a **key-existence test** — read-only, cheap, and decisive. `Existence` is +/// that mapping and it is the ONLY thing the sweep concludes from a reply: `UNSUPPORTED(3)`, anything +/// else, and the unlabelled result byte on WHOOP 4.0 all stay `inconclusive` rather than being folded +/// into either answer. +/// +/// ## 3. How the fallback candidates were DERIVED +/// +/// Not by free association. Every name is a cross-product of two things already in this repo. +/// +/// **A — morphology**, the templates the seventeen CONFIRMED key names follow (the sixteen in +/// `Whoop5Config.enableR22Sequence`, plus `whoop_live_hr_in_adv_ind_pkt`, the Broadcast-HR key NOOP has +/// written since #181 and which #890 uses as its known-good device-config control): +/// +/// ``` +/// T1 enable__packets enable_r22_packets +/// T2 enable__v_packets enable_r22_v2_packets … enable_r22_v8_packets +/// T3 disable___packets disable_pip_r26_packets +/// T4 make__visible make_hrfm_visible +/// T5 __switching hr_ch_switching, ir_hw_switching +/// T6 _detect_bias wear_detect_bias +/// T7 enable__gen5 enable_passive_strap_fit_gen5 +/// T8 enable_sig[_during_sleep] enable_sig11_during_sleep, enable_sig12 +/// T9 _inhibit_ dorset_inhibit_wpt +/// T10 whoop__in_ whoop_live_hr_in_adv_ind_pkt (device-config namespace) +/// ``` +/// +/// **B — vocabulary**, the subsystem and revision tokens the firmware uses about itself. Two in-repo +/// sources, no others: +/// +/// - the `CommandNumber` table: `optical` (107 `ENABLE_OPTICAL_DATA`, 108 `TOGGLE_OPTICAL_MODE`), +/// `labrador` (124 `TOGGLE_LABRADOR_DATA_GENERATION`, 125 `TOGGLE_LABRADOR_RAW_SAVE`, 139 +/// `TOGGLE_LABRADOR_FILTERED`), `research` (131/132), `afe` (61/62), `led`+`drive` (39/40), `raw` +/// (81/82), and the revision tokens `r7` (16 `TOGGLE_R7_DATA_COLLECTION`), `r10`/`r11` (63 +/// `SEND_R10_R11_REALTIME`), `r20`/`r21` (153/154 `TOGGLE_PERSISTENT_R20`/`_R21`); +/// - the strap's own plaintext console log, whose subsystem tags this package already documents +/// (`Interpreter.swift`): `SENSORS: AFE configuration changed`, and — directly on point — +/// **`SIGPROC: generated a valid SPO2 during sleep`**. That single line pairs the firmware's SpO2 +/// computation with the `SIGPROC` tag and with the exact `during sleep` phrasing the confirmed key +/// `enable_sig11_during_sleep` uses, which is the strongest in-repo reason to think the `sig` series +/// is where an oxygen gate would live. +/// +/// Every candidate is (template × token). None is a product name invented in English — which matters, +/// because the eight plain-English oxygen names in `retiredKeys` were all asked of a real WHOOP 5 MG and +/// all came back FAILURE. This firmware names things `sig11`, `dorset`, `pip`, `wpt`, `tia`. It does not +/// appear to name them `blood_oxygen`. +/// +/// **They are still guesses.** A candidate is a question, not a claim; the sweep's answer is `Existence`, +/// and a fully-negative sweep rules out a whole family of names, which is a publishable result. +/// +/// ## Contributing a name +/// +/// `catalogue` is the one place to edit. Adding an entry adds one round-trip and nothing else. Say which +/// template and which token it comes from, and keep the Kotlin twin +/// (`android/…/protocol/ConfigKeySweep.kt`) in lockstep — a unit test asserts the two lists are identical. +public enum ConfigKeySweep { + + // MARK: - Enumeration opcodes (read-only) + + /// `START_DEVICE_CONFIG_KEY_EXCHANGE` (115 / 0x73) — ask the strap how many device-config keys it + /// knows. Read-only, and the structural twin of `START_FF_KEY_EXCHANGE` (117), which #872 shipped and + /// a real strap answered. Named in this repo's `CommandNumber` table; never before sent by NOOP. + public static let startDeviceConfigKeyExchangeCmd: UInt8 = 115 + + /// `SEND_NEXT_DEVICE_CONFIG` (116 / 0x74) — advance the strap's own cursor and report one key name. + /// Read-only; the twin of `SEND_NEXT_FF` (118). Like 118 it carries a CURSOR, not an index: the same + /// body is sent repeatedly and the strap walks its own list. + public static let sendNextDeviceConfigCmd: UInt8 = 116 + + /// Request body for both enumeration commands: the inner b3 byte `0x01`, the convention `GET_HELLO`, + /// the SET_CONFIG family and the 117/118 pair all use. + public static let enumerationRequestBody: [UInt8] = [0x01] + + /// Hard ceiling on 116 round-trips in one probe, independent of the count the strap announces. A + /// firmware that answers with a nonsense count — or never advances its own cursor — must not be able + /// to drive an unbounded write loop on the command characteristic. + public static let maxEnumerationSteps = 40 + + /// Ceiling on how many enumerated device-config key names the probe then reads VALUES for, so a long + /// key list cannot spend the whole step budget. + public static let maxEnumeratedValueReads = 40 + + // MARK: - The oracle + + /// What one 121/128 reply says about whether the key NAME exists. The result code, not the value, is + /// the signal — that is what makes a name sweep possible at all. + /// + /// Confirmed on a WHOOP 5 MG (WS50_r03): a key the firmware knows answers `SUCCESS(1)` and carries a + /// value byte; a name it does not know answers `FAILURE(0)`. Every other code — including + /// `UNSUPPORTED(3)`, and the result byte on WHOOP 4.0 where this codebase has never pinned its + /// meaning — is `inconclusive` rather than being coerced into an answer. + public enum Existence: String, Equatable, Sendable { + /// `result = SUCCESS(1)`. The firmware has this key. + case exists + /// `result = FAILURE(0)`. The firmware has no key by this name. + case unknown + /// Any other result code, or none at all. Says nothing either way. + case inconclusive + + /// Fixed-width label so the Swift and Kotlin reports line up byte for byte. + public var label: String { rawValue } + } + + /// Map a 5/MG result code onto the oracle. `nil` — WHOOP 4.0, where this codebase has not established + /// the byte's meaning — is `inconclusive`, never `unknown`. + public static func existence(resultCode: Int?) -> Existence { + switch resultCode { + case 1: return .exists + case 0: return .unknown + default: return .inconclusive + } + } + + // MARK: - Candidates (the fallback) + + /// Which namespace a candidate is asked through. The two are separate: 117/118 enumerated the sixteen + /// R22 flags and nothing else, while the Broadcast-HR key `whoop_live_hr_in_adv_ind_pkt` (#181) is a + /// device-config key and is not among them. The probe's cross-namespace step can override this at run + /// time if one verb turns out to serve both. + public enum Namespace: String, Equatable, Sendable { + case featureFlag + case deviceConfig + } + + /// Which derivation produced a candidate. Groups the report, and — more usefully — lets a negative + /// sweep rule out a whole FAMILY of names rather than just a list of strings. + public enum Derivation: String, CaseIterable, Equatable, Sendable { + case sigSeries + case r22VersionGaps + case revisionSlot + case opticalAfe + case labradorEcg + case researchHighRate + case sigprocOxygen + case deviceConfigNamespace + + /// Section heading in the report: states the derivation, not just a label, so a strap log pasted + /// into an issue explains where the names came from. + public var title: String { + switch self { + case .sigSeries: + return "sig series (T8) — the firmware numbers its signal chains; sig11/sig12 are the two we have" + case .r22VersionGaps: + return "r22 version gaps (T2) — NOOP writes v2…v6 and v8; v7 and v1 are absent from an otherwise contiguous run" + case .revisionSlot: + return "revision slot (T1/T3) — r7/r10/r11/r20/r21 are named in this repo's own CommandNumber table" + case .opticalAfe: + return "optical + AFE (T1/T4/T5) — 107 ENABLE_OPTICAL_DATA, 108 TOGGLE_OPTICAL_MODE, 61/62 AFE_PARAMETERS" + case .labradorEcg: + return "labrador / ECG (T1/T4) — 124/125/139 name LABRADOR in this repo's CommandNumber table" + case .researchHighRate: + return "research + high-rate (T1/T4) — 131/132 RESEARCH_PACKET, 81/82 RAW_DATA, and hrfm from make_hrfm_visible" + case .sigprocOxygen: + return "SIGPROC + oxygen (T1/T4/T5/T7) — the strap's console log says \"SIGPROC: generated a valid SPO2 during sleep\"" + case .deviceConfigNamespace: + return "device-config namespace (T10) — the whoop__in_ shape of the one key we know" + } + } + } + + /// One candidate key name: a QUESTION for the oracle, with the derivation that produced it and the + /// namespace it is asked through. None has been observed on a wire, in a capture, or in any table. + public struct Candidate: Equatable, Sendable { + public let key: String + public let derivation: Derivation + public let namespace: Namespace + public init(key: String, derivation: Derivation, namespace: Namespace = .featureFlag) { + self.key = key + self.derivation = derivation + self.namespace = namespace + } + } + + /// Build one derivation's candidates without repeating it on every line. + private static func names(_ derivation: Derivation, _ namespace: Namespace, + _ keys: [String]) -> [Candidate] { + keys.map { Candidate(key: $0, derivation: derivation, namespace: namespace) } + } + + /// **The candidate catalogue — the one place to add a name.** Every entry is (template × token); both + /// lists are in this file's doc comment. Order is the sweep order, strongest derivation first. + public static let catalogue: [Candidate] = + // T8. sig11 and sig12 are the only members of this series anyone here has seen, and the strap's own + // console tag SIGPROC — the tag on the line "generated a valid SPO2 during sleep" — is the likeliest + // expansion of "sig". A contiguous walk of the number line needs no guessing at all: it asks which + // N exist. Crossing the survivors with qualifiers is a cheap second pass once the line is known. + names(.sigSeries, .featureFlag, [ + "enable_sig1", "enable_sig2", "enable_sig3", "enable_sig4", "enable_sig5", + "enable_sig6", "enable_sig7", "enable_sig8", "enable_sig9", "enable_sig10", + "enable_sig13", "enable_sig14", "enable_sig15", "enable_sig16", + // The two qualifier swaps on the pair we do have: sig11 without `_during_sleep`, sig12 with it. + "enable_sig11", "enable_sig12_during_sleep", + ]) + // T2. The series NOOP writes is v2, v3, v4, v5, v6, v8 — v7 is MISSING from an otherwise contiguous + // run, and there is no v1. Interpolating a hole in an OBSERVED series is the cheapest possible test + // that the oracle finds keys NOOP does not already know: if v7 answers SUCCESS, the method is + // proven on the first run and every other family becomes worth extending. + + names(.r22VersionGaps, .featureFlag, [ + "enable_r22_v1_packets", "enable_r22_v7_packets", + "enable_r22_v9_packets", "enable_r22_v10_packets", + ]) + // T1/T3. The revision slot. r22 and r26 appear in the confirmed keys; r7, r10, r11, r20 and r21 + // appear as revision tokens in this repo's own CommandNumber table. r16 and r17 fill the gap + // between the two attested clusters, and are the pair worth settling either way. + + names(.revisionSlot, .featureFlag, [ + "enable_r7_packets", "enable_r10_packets", "enable_r11_packets", + "enable_r16_packets", "enable_r17_packets", + "enable_r20_packets", "enable_r21_packets", + // The polarity swap on disable_pip_r26_packets, the one T3 instance there is. + "enable_pip_r26_packets", + ]) + // T1/T4/T5. SpO2 is an optical measurement, so if a config key gates it, the firmware's optical and + // analog-front-end vocabulary is where it would be spelled. Note 107 ENABLE_OPTICAL_DATA is + // literally the T1 template already, which is why enable_optical_data leads. + + names(.opticalAfe, .featureFlag, [ + "enable_optical_data", "enable_optical_packets", "make_optical_visible", + "enable_afe_packets", "red_hw_switching", "green_hw_switching", + ]) + // T1/T4. LABRADOR is the firmware's own codename for a data path this repo's CommandNumber table + // gives three verbs (124 data generation, 125 raw save, 139 filtered) and which NOOP has never + // enabled. Its DATA_GENERATION / RAW_SAVE / FILTERED triad mirrors the ECG family's shape. + + names(.labradorEcg, .featureFlag, [ + "enable_labrador_packets", "enable_labrador_raw_save", "enable_labrador_filtered", + "make_labrador_visible", "enable_ecg_packets", + ]) + // T1/T4. The research and high-rate paths: 131/132 SET/GET_RESEARCH_PACKET, 81/82 START/STOP_RAW_DATA, + // and `hrfm`, which the confirmed key make_hrfm_visible already names. + + names(.researchHighRate, .featureFlag, [ + "enable_research_packets", "make_research_visible", + "enable_raw_packets", "enable_hrfm_packets", + ]) + // T1/T4/T5/T7, plus the console tag. The eight plain-English oxygen names in `retiredKeys` all + // returned FAILURE, so these deliberately do not repeat that approach: each is a CONFIRMED template + // with `spo2` dropped into the token slot, and the last two use the strap's own `SIGPROC` tag and + // its own "during sleep" phrasing. + + names(.sigprocOxygen, .featureFlag, [ + "make_spo2_visible", "enable_spo2_during_sleep", "enable_spo2_gen5", + "spo2_ch_switching", "disable_spo2_packets", + "enable_sigproc_spo2", "sigproc_spo2_during_sleep", + ]) + // T10, and the only family asked through 121 by default. The one confirmed device-config key is + // `whoop_live_hr_in_adv_ind_pkt`: `whoop_` + a live metric + the transport it rides. Swapping the + // metric is the most direct template swap available in that namespace — and it is the namespace + // 115/116 would have enumerated outright, so these only get asked when enumeration is refused. + + names(.deviceConfigNamespace, .deviceConfig, [ + "whoop_live_hrv_in_adv_ind_pkt", "whoop_live_spo2_in_adv_ind_pkt", + "whoop_live_temp_in_adv_ind_pkt", "whoop_live_ecg_in_adv_ind_pkt", + ]) + + /// Names ALREADY ANSWERED `FAILURE(0)` by a real WHOOP 5 MG (WS50_r03) — the firmware has no key by + /// any of them. Kept OUT of `catalogue` so nobody spends round-trips re-asking, and kept here rather + /// than deleted so nobody proposes them again. + /// + /// They are also the evidence for how `catalogue` is built: all eight are product English ("blood + /// oxygen", "pulse ox", "subscription"), and all eight are wrong. + public static let retiredKeys: [String] = [ + "enable_spo2", + "enable_spo2_packets", + "spo2_enable", + "enable_blood_oxygen", + "blood_oxygen_enable", + "enable_pulse_ox", + "enable_oxygen_packets", + "spo2_subscription_enabled", + ] + + // MARK: - Batching + + /// How many candidate names one run may test. Bounds the wall clock: with the read verbs live a + /// round-trip is one BLE write plus one notification, so a whole run stays inside a couple of minutes. + /// `catalogue` is smaller than this today, so every run tests all of it; the batching exists so a + /// catalogue GROWN past the budget truncates VISIBLY and resumably instead of silently. + public static let maxKeysPerRun = 64 + + /// One run's slice of the catalogue. + public struct Batch: Equatable, Sendable { + /// The candidates this run may test, in order. + public let candidates: [Candidate] + /// Zero-based index of the first candidate in the slice (the report shows it 1-based). + public let start: Int + /// Cursor to hand the NEXT run. Wraps to 0 once a slice reaches the end of the catalogue. + public let nextCursor: Int + /// Names in the catalogue this run does not reach. + public var remaining: Int { ConfigKeySweep.catalogue.count - start - candidates.count } + /// True when this slice ends at the end of the catalogue. + public var completesCatalogue: Bool { remaining == 0 } + public init(candidates: [Candidate], start: Int, nextCursor: Int) { + self.candidates = candidates + self.start = start + self.nextCursor = nextCursor + } + } + + /// The slice to test starting at `cursor`. A cursor outside the catalogue — negative, or left over + /// from a longer catalogue — restarts at 0 rather than wasting a run. A slice never wraps mid-batch: + /// it stops at the end and hands back 0, so no name is asked twice in one run. + /// + /// `limit` defaults to `maxKeysPerRun` and is a parameter only so tests can exercise the + /// truncate-and-resume path today, while the catalogue is still smaller than one run's budget. + public static func batch(from cursor: Int, limit: Int = maxKeysPerRun) -> Batch { + guard !catalogue.isEmpty, limit > 0 else { + return Batch(candidates: [], start: 0, nextCursor: 0) + } + let start = (cursor < 0 || cursor >= catalogue.count) ? 0 : cursor + let end = min(start + limit, catalogue.count) + return Batch(candidates: Array(catalogue[start..= catalogue.count ? 0 : end) + } +} diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift index 49ce3cc6fa..a7d40aadbe 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift @@ -19,16 +19,23 @@ import Foundation /// /// ## What this establishes, and the honest failure case /// -/// **Both target opcodes may simply not be implemented in firmware.** The repo's own protocol table -/// (`Resources/whoop_protocol.json`, `CommandNumber`) names 121 `GET_DEVICE_CONFIG_VALUE` and 128 -/// `GET_FF_VALUE`, but a name in a table is not a served verb: opcode 96 (`enterHighFreqHistoricalMode`) -/// is a standing example of a number the table carries that nothing in the wild sends. So the probe's -/// PRIMARY deliverable is a clean verdict per verb — **answered**, **rejected as UNSUPPORTED**, or -/// **silent** — and "both verbs are unimplemented" is a useful, publishable result, not a failure. +/// Both read verbs answered on a real WHOOP 5 MG (WS50_r03), and the reply turned out to be an +/// **existence oracle**: `SUCCESS(1)` for a key name the firmware has, `FAILURE(0)` for one it does not. +/// That is what makes a key-name search possible at all, and `ConfigKeySweep` is where it lives. /// -/// Only if a verb answers does the probe go on to read values: first the sixteen key names NOOP already -/// has (`Whoop5Config.enableR22Sequence` — their VALUES on a real strap have never been read, only -/// written), then a short list of GUESSED oxygen-related key names against the device-config namespace. +/// The probe now runs a plan built around one principle: **ask the strap before guessing.** It opens with +/// the DEVICE-CONFIG ENUMERATION pair `START_DEVICE_CONFIG_KEY_EXCHANGE` (115) and +/// `SEND_NEXT_DEVICE_CONFIG` (116) — named in the repo's own `CommandNumber` table, never sent by +/// anything here, and the structural twin of the 117/118 feature-flag pair #872 shipped and a strap +/// answered. If 115/116 answer, the strap lists its own device-config keys and no name needs guessing at +/// all; the candidate sweep is skipped and the report says so. A clean "115/116 are not served" is +/// equally useful and publishable — it is what promotes the guessing fallback from a shortcut to the only +/// available method. +/// +/// After enumeration the probe reads the values of keys already known to exist (the sixteen in +/// `Whoop5Config.enableR22Sequence`, plus anything enumeration returned), spends two round-trips +/// establishing whether the two namespaces are actually separate, and only then — and only when +/// enumeration produced nothing — asks the guessed names in `ConfigKeySweep.catalogue`. /// /// ## Read-only by construction /// @@ -79,9 +86,15 @@ public enum DeviceConfigReadProbe { /// This probe never sends it. public static let setFeatureFlagValueCmd: UInt8 = 120 - /// The complete set of opcodes this probe may put on the wire. The BLE send path admits these two and - /// **only** these two while a probe is in flight; `isReadOnlyOpcode` is the predicate it asks. - public static let readOnlyOpcodes: Set = [getDeviceConfigValueCmd, getFeatureFlagValueCmd] + /// The complete set of opcodes this probe may put on the wire: the two VALUE reads above, plus the two + /// DEVICE-CONFIG ENUMERATION verbs the probe now tries first (`ConfigKeySweep`, 115/116 — the + /// structural twins of the 117/118 pair #872 shipped and a real strap answered read-only). The BLE + /// send path admits these four and **only** these four while a probe is in flight; `isReadOnlyOpcode` + /// is the predicate it asks, and unit tests prove it rejects 119, 120 and every other opcode. + public static let readOnlyOpcodes: Set = [ + getDeviceConfigValueCmd, getFeatureFlagValueCmd, + ConfigKeySweep.startDeviceConfigKeyExchangeCmd, ConfigKeySweep.sendNextDeviceConfigCmd, + ] /// The config WRITE verbs, which this probe must never emit. Kept as a named set so the read-only /// contract is testable as a property of the allowlist rather than as a claim in a comment. @@ -98,8 +111,14 @@ public enum DeviceConfigReadProbe { /// Hard ceiling on round-trips in one probe, independent of how many keys the plan holds. A firmware /// that answers oddly must not be able to drive an unbounded write loop on the command characteristic. - /// The full plan is 2 discovery + 16 known flags + the candidate list, comfortably under this. - public static let maxSteps = 64 + /// + /// The plan is 1 enumerate-start + up to `ConfigKeySweep.maxEnumerationSteps` enumerate-next + 2 + /// discovery + 2 cross-namespace + 16 known flags, and then EITHER up to + /// `ConfigKeySweep.maxEnumeratedValueReads` value reads (when enumeration produced a list) OR up to + /// `ConfigKeySweep.maxKeysPerRun` candidate names (when it did not) — never both, because guessing is + /// pointless once the strap has handed over its own list. Worst case is 101 round-trips, comfortably + /// under this; a plan that somehow exceeds it stops with a named reason rather than truncating silently. + public static let maxSteps = 128 /// The one device-config key NOOP already knows a real strap accepts: the Broadcast-HR flag written /// via `SET_DEVICE_CONFIG_VALUE` and hardware-validated in #181. Used as the discovery key for opcode @@ -107,26 +126,6 @@ public enum DeviceConfigReadProbe { /// about the key. public static let deviceConfigDiscoveryKey = "whoop_live_hr_in_adv_ind_pkt" - /// **GUESSES.** Candidate oxygen-related key names to try against the device-config namespace. None of - /// these has been observed on a wire, in a capture, or in any protocol table — they are constructed - /// from the naming conventions the *known* keys follow (`enable_…`, `…_enable`, snake_case, and the - /// `whoop_…` prefix the one known device-config key uses). They are reported as guesses everywhere - /// they appear. - /// - /// This list is the one place to extend. Adding a name here adds a probe step and nothing else — the - /// same pattern would let a future run try, say, `enable_sig13` (the undocumented `enable_sig11…` / - /// `enable_sig12` series continued) without touching any other code. - public static let oxygenCandidateKeys: [String] = [ - "enable_spo2", - "enable_spo2_packets", - "spo2_enable", - "enable_blood_oxygen", - "blood_oxygen_enable", - "enable_pulse_ox", - "enable_oxygen_packets", - "spo2_subscription_enabled", - ] - /// The request body for one read: the inner b3 byte `0x01`, then the key name as ASCII NUL-padded to /// `nameFieldBytes`. A name longer than the field is truncated to it, exactly as the SET side does. public static func requestBody(key: String) -> [UInt8] { @@ -175,6 +174,12 @@ public enum DeviceConfigReadProbe { /// (wrong body shape, or an unknown key). public var isFailure: Bool { resultCode == 0 } + /// What this reply says about whether the key NAME exists, per the oracle a real WHOOP 5 MG + /// established: `SUCCESS(1)` = the firmware has this key, `FAILURE(0)` = it does not, anything + /// else (and WHOOP 4.0, where the result byte's meaning is not pinned here) = inconclusive. + /// Deliberately reads the RESULT CODE and nothing else — no inference from the record bytes. + public var existence: ConfigKeySweep.Existence { ConfigKeySweep.existence(resultCode: resultCode) } + /// Raw record bytes as lowercase space-separated hex; always reported, whatever else decodes. public var recordHex: String { DeviceConfigReadProbe.hex(record) } @@ -262,40 +267,57 @@ public enum DeviceConfigReadProbe { : "0x\(String(format: "%02x", v))" } } - // MARK: - Report -/// The running result of one device-config read probe: the plan it walks, the per-verb verdict it -/// reaches, the values it manages to read, and the copyable transcript. Pure and order-dependent -/// (`nextStep` → `note…` → `nextStep` → …), so `swift test` covers the whole probe — plan, verdicts, -/// rendering — without a strap. Kotlin twin: `DeviceConfigReadProbeReport` in -/// `android/…/protocol/DeviceConfigReadProbe.kt`; the rendered text is byte-identical across platforms so -/// a shared strap log reads the same either side. +/// The running result of one config probe: the strap's own device-config key list when it will give one, +/// the per-verb verdict, the values read, the candidate sweep when guessing is still necessary, and the +/// copyable transcript. Pure and order-dependent (`nextStep` → `note…` → `nextStep` → …), so `swift test` +/// covers the whole probe — plan, verdicts, rendering — without a strap. Kotlin twin: +/// `DeviceConfigReadProbeReport` in `android/…/protocol/DeviceConfigReadProbe.kt`; the rendered text is +/// byte-identical across platforms so a shared strap log reads the same either side. +/// +/// The plan is ordered so the cheapest decisive question is asked first: +/// +/// 1. **enumerate** — 115 then repeated 116. If the strap answers, it has just listed its own +/// device-config keys and no name needs guessing. +/// 2. **discovery** — one 128 read and one 121 read, each against a key that verb should know, to +/// establish whether the VALUE verbs answer at all. +/// 3. **crossNamespace** — ask each verb for the OTHER namespace's known key. Settles in two round-trips +/// whether the namespaces are really separate, which halves every future sweep if they are not. +/// 4. **knownKey** — read the values of the sixteen flags NOOP writes, plus any key enumeration produced. +/// 5. **candidate** — the guessed-name sweep, and **only when enumeration produced no list**. public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// Which part of the plan a step belongs to. Drives both the ordering and the report's sections. public enum Group: String, Equatable, Sendable { - /// One round-trip per verb against a key that verb should know, to establish whether it answers. + /// 115/116: ask the strap to list its own device-config keys. + case enumerate + /// One round-trip per VALUE verb against a key that verb should know. case discovery - /// The sixteen flag names NOOP already writes — reading their VALUES is new information. - case knownFlag - /// Guessed oxygen-related key names. Labelled as guesses everywhere. + /// Each VALUE verb asked for the other namespace's known key. + case crossNamespace + /// Keys already known to exist — the sixteen flags, plus anything enumeration returned. + case knownKey + /// Guessed key names. Labelled as guesses everywhere. case candidate } - /// One planned round-trip. + /// One planned round-trip. `derivation` is set only for candidate steps. public struct Step: Equatable, Sendable { public let opcode: UInt8 public let key: String public let group: Group - public init(opcode: UInt8, key: String, group: Group) { + public let derivation: ConfigKeySweep.Derivation? + public init(opcode: UInt8, key: String, group: Group, + derivation: ConfigKeySweep.Derivation? = nil) { self.opcode = opcode self.key = key self.group = group + self.derivation = derivation } } - /// What one verb has been shown to do. `untried` until its discovery step resolves. + /// What one verb has been shown to do. `untried` until its first step resolves. public enum VerbStatus: String, Equatable, Sendable { case untried /// A decodable COMMAND_RESPONSE came back and was not an explicit UNSUPPORTED. @@ -318,14 +340,21 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { public let value: UInt8? public let resultCode: Int? public let recordHex: String + public let derivation: ConfigKeySweep.Derivation? public init(group: Group, opcode: UInt8, key: String, value: UInt8?, - resultCode: Int?, recordHex: String) { + resultCode: Int?, recordHex: String, + derivation: ConfigKeySweep.Derivation? = nil) { self.group = group self.opcode = opcode self.key = key self.value = value self.resultCode = resultCode self.recordHex = recordHex + self.derivation = derivation + } + /// The oracle's verdict on whether this key NAME exists. + public var existence: ConfigKeySweep.Existence { + ConfigKeySweep.existence(resultCode: resultCode) } } @@ -336,8 +365,8 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// The flag names whose values to read — supplied by the caller from `Whoop5Config.enableR22Sequence` /// so this file never restates them. public let knownFlagKeys: [String] - /// The guessed oxygen key names — supplied by the caller from `DeviceConfigReadProbe`. - public let candidateKeys: [String] + /// This run's slice of the candidate catalogue, and the cursor to hand the next run. + public let batch: ConfigKeySweep.Batch // MARK: Accumulated state @@ -345,9 +374,24 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { public private(set) var featureFlagVerb: VerbStatus = .untried /// Status of `GET_DEVICE_CONFIG_VALUE` (121). public private(set) var deviceConfigVerb: VerbStatus = .untried + /// Status of the device-config ENUMERATION pair (115/116), taken as one verb: 116 cannot be asked + /// without 115 having answered, so a single verdict describes the pair. + public private(set) var enumerationVerb: VerbStatus = .untried + /// Device-config key names the strap listed for itself. The headline result when it is non-empty. + public private(set) var enumeratedKeys: [String] = [] + /// The key count `START_DEVICE_CONFIG_KEY_EXCHANGE` announced, when it answered. + public private(set) var enumeratedCount: Int? + /// Entries the strap called real keys whose NAME did not decode, stepped over rather than trusted as + /// a terminator (the discipline #874 established for the 117/118 walk). + public private(set) var enumerationSkipped = 0 + /// `GET_FF_VALUE(128)` asked for the known DEVICE-CONFIG key: does the flag verb see that namespace? + public private(set) var featureFlagVerbOnDeviceConfigKey: ConfigKeySweep.Existence? + /// `GET_DEVICE_CONFIG_VALUE(121)` asked for a known FLAG key: does the device-config verb see that one? + public private(set) var deviceConfigVerbOnFlagKey: ConfigKeySweep.Existence? /// Every reading, in the order the strap served it. public private(set) var readings: [Reading] = [] - /// Trace lines: one per round-trip plus any failure notes. + /// Trace lines. Candidate round-trips are summarised in their own section rather than repeated here, + /// EXCEPT the ones that are not a plain `unknown` — a hit or an odd reply always appears in full. public private(set) var trace: [String] = [] /// Round-trips attempted. Bounds the walk against `DeviceConfigReadProbe.maxSteps`. public private(set) var steps = 0 @@ -356,15 +400,17 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { // MARK: Plan cursors - private var phase = 0 // 0 discovery, 1 known flags, 2 candidates, 3 done + private var phase = 0 // 0 enumerate, 1 discovery, 2 cross, 3 known keys, 4 candidates, 5 done private var cursor = 0 - /// `"opcode:key"` pairs already attempted, so discovery's key is not re-read in a later phase. + private var enumPhase = 0 // 0 send 115, 1 send 116 repeatedly, 2 done + private var enumSteps = 0 + /// `"opcode:key"` pairs already attempted, so an earlier phase's key is not re-read in a later one. private var attempted: Set = [] - public init(family: DeviceFamily, knownFlagKeys: [String], candidateKeys: [String]) { + public init(family: DeviceFamily, knownFlagKeys: [String], batch: ConfigKeySweep.Batch) { self.family = family self.knownFlagKeys = knownFlagKeys - self.candidateKeys = candidateKeys + self.batch = batch } // MARK: - Plan @@ -374,15 +420,19 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { public mutating func nextStep() -> Step? { guard steps < DeviceConfigReadProbe.maxSteps else { stopReason = stopReason ?? "safety cap of \(DeviceConfigReadProbe.maxSteps) round-trips reached" - phase = 3 + phase = 5 return nil } - while phase < 3 { + while phase < 5 { if let step = stepInCurrentPhase() { cursor += 1 - let id = "\(step.opcode):\(step.key)" - if attempted.contains(id) { continue } - attempted.insert(id) + // Enumeration deliberately repeats one (opcode, key) pair — the strap walks its own + // cursor — so it is the one group the de-duplicator must not police. + if step.group != .enumerate { + let id = "\(step.opcode):\(step.key)" + if attempted.contains(id) { continue } + attempted.insert(id) + } steps += 1 return step } @@ -392,12 +442,32 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { return nil } - /// One candidate step from the current phase, or nil when that phase is exhausted. - private func stepInCurrentPhase() -> Step? { + /// One step from the current phase, or nil when that phase is exhausted. + private mutating func stepInCurrentPhase() -> Step? { switch phase { case 0: - // Discovery: one round-trip per verb, each against a key that verb has a reason to know. - // 128 gets a flag NOOP writes; 121 gets the Broadcast-HR key, hardware-validated in #181. + // Ask the strap to list its own device-config keys. 115 once; then 116 until the strap says + // stop, exactly as the 117/118 walk does. + switch enumPhase { + case 0: + return Step(opcode: ConfigKeySweep.startDeviceConfigKeyExchangeCmd, key: "", + group: .enumerate) + case 1: + guard enumSteps < ConfigKeySweep.maxEnumerationSteps else { + stopReason = stopReason + ?? "device-config enumeration hit its cap of \(ConfigKeySweep.maxEnumerationSteps) entries; the rest of the plan still ran" + enumPhase = 2 + return nil + } + enumSteps += 1 + return Step(opcode: ConfigKeySweep.sendNextDeviceConfigCmd, key: "", group: .enumerate) + default: + return nil + } + case 1: + // Discovery: one round-trip per VALUE verb, each against a key that verb has a reason to know. + // 128 gets a flag NOOP writes; 121 gets the Broadcast-HR key NOOP has written since #181, so a + // FAILURE there is evidence about the VERB, not about the key. let plan: [Step] = [ Step(opcode: DeviceConfigReadProbe.getFeatureFlagValueCmd, key: knownFlagKeys.first ?? DeviceConfigReadProbe.deviceConfigDiscoveryKey, @@ -408,45 +478,134 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { ] guard cursor < plan.count else { return nil } return plan[cursor] - case 1: - // Known flag values, through whichever verb answered — 128 by preference (it owns the - // feature-flag namespace), 121 as a fallback worth one look if only it survived. - guard let verb = verbForFlags(), cursor < knownFlagKeys.count else { return nil } - return Step(opcode: verb, key: knownFlagKeys[cursor], group: .knownFlag) case 2: - // Guessed oxygen keys, through the device-config verb by preference — that namespace is the - // one this probe exists to reach. - guard let verb = verbForCandidates(), cursor < candidateKeys.count else { return nil } - return Step(opcode: verb, key: candidateKeys[cursor], group: .candidate) + // Cross-namespace: each answering verb asked for the OTHER namespace's known-good key. Two + // round-trips that settle whether the namespaces are actually separate. + var plan: [Step] = [] + if featureFlagVerb == .answered { + plan.append(Step(opcode: DeviceConfigReadProbe.getFeatureFlagValueCmd, + key: DeviceConfigReadProbe.deviceConfigDiscoveryKey, + group: .crossNamespace)) + } + if deviceConfigVerb == .answered, let flag = knownFlagKeys.first { + plan.append(Step(opcode: DeviceConfigReadProbe.getDeviceConfigValueCmd, + key: flag, group: .crossNamespace)) + } + guard cursor < plan.count else { return nil } + return plan[cursor] + case 3: + let plan = knownKeyPlan + guard cursor < plan.count else { return nil } + let entry = plan[cursor] + guard let verb = verb(for: entry.namespace) else { return nil } + return Step(opcode: verb, key: entry.key, group: .knownKey) + case 4: + // Guessing is the FALLBACK. If the strap enumerated its own device-config keys there is + // nothing to guess at in that namespace, so the sweep is skipped and said so in the report. + guard enumeratedKeys.isEmpty, cursor < batch.candidates.count else { return nil } + let candidate = batch.candidates[cursor] + guard let verb = verb(for: candidate.namespace) else { return nil } + return Step(opcode: verb, key: candidate.key, group: .candidate, + derivation: candidate.derivation) default: return nil } } - /// The verb to read feature-flag values through, or nil when neither answered. - private func verbForFlags() -> UInt8? { - if featureFlagVerb == .answered { return DeviceConfigReadProbe.getFeatureFlagValueCmd } - if deviceConfigVerb == .answered { return DeviceConfigReadProbe.getDeviceConfigValueCmd } - return nil + /// The keys whose values are worth reading because they are already known to exist: the sixteen flags + /// NOOP writes, then whatever the strap enumerated for itself (capped, and never re-listing a flag). + private var knownKeyPlan: [(key: String, namespace: ConfigKeySweep.Namespace)] { + var plan = knownFlagKeys.map { (key: $0, namespace: ConfigKeySweep.Namespace.featureFlag) } + for key in enumeratedKeys.prefix(ConfigKeySweep.maxEnumeratedValueReads) + where !knownFlagKeys.contains(key) { + plan.append((key: key, namespace: .deviceConfig)) + } + return plan } - /// The verb to try guessed device-config keys through, or nil when neither answered. - private func verbForCandidates() -> UInt8? { - if deviceConfigVerb == .answered { return DeviceConfigReadProbe.getDeviceConfigValueCmd } - if featureFlagVerb == .answered { return DeviceConfigReadProbe.getFeatureFlagValueCmd } - return nil + /// The verb to ask a key of, or nil when neither VALUE verb answered. + /// + /// A verb SHOWN in this same run to serve the other namespace too is preferred for everything — fewer + /// moving parts, and the evidence is from this run rather than an assumption. Otherwise each namespace + /// uses its own verb, falling back to the other one as a look worth taking. + private func verb(for namespace: ConfigKeySweep.Namespace) -> UInt8? { + if deviceConfigVerbOnFlagKey == .exists, deviceConfigVerb == .answered { + return DeviceConfigReadProbe.getDeviceConfigValueCmd + } + if featureFlagVerbOnDeviceConfigKey == .exists, featureFlagVerb == .answered { + return DeviceConfigReadProbe.getFeatureFlagValueCmd + } + let ff = featureFlagVerb == .answered ? DeviceConfigReadProbe.getFeatureFlagValueCmd : nil + let dc = deviceConfigVerb == .answered ? DeviceConfigReadProbe.getDeviceConfigValueCmd : nil + return namespace == .featureFlag ? (ff ?? dc) : (dc ?? ff) } // MARK: - Notes - /// Record one decoded reply. + /// Record the `START_DEVICE_CONFIG_KEY_EXCHANGE` reply. An implausible count is reported but never + /// trusted as a loop bound — the walk is bounded by `ConfigKeySweep.maxEnumerationSteps` and by the + /// strap's own end marker. + public mutating func noteEnumerationStart(_ r: FeatureFlagProbe.StartResponse) { + enumeratedCount = r.count + if r.resultCode == 3 { + enumerationVerb = .unsupported + enumPhase = 2 + trace.append("START_DEVICE_CONFIG_KEY_EXCHANGE(115) → result=UNSUPPORTED(3) — the firmware does not serve this verb") + return + } + enumerationVerb = .answered + enumPhase = 1 + var line = "START_DEVICE_CONFIG_KEY_EXCHANGE(115) →" + if let c = r.resultCode { line += " result=\(FeatureFlagProbe.resultLabel(c))(\(c))" } + line += " revision=\(r.revision) count=\(r.count)" + if !r.countIsPlausible { line += " (implausible — walked to the strap's own end marker instead)" } + trace.append(line) + } + + /// Record one `SEND_NEXT_DEVICE_CONFIG` reply. Returns true when the walk should continue. + /// + /// Mirrors the #874 discipline on the 117/118 walk: the strap's own end marker terminates the walk, + /// but a name OUR parser declines (`isSkippable`) is counted and stepped over — one undecodable entry + /// must not throw away every key after it. + @discardableResult + public mutating func noteEnumerationNext(_ r: FeatureFlagProbe.NextResponse) -> Bool { + if r.isExhausted { + enumPhase = 2 + trace.append("SEND_NEXT_DEVICE_CONFIG(116) → end of list (index=\(r.index) validKey=\(r.validKey))") + return false + } + if r.isSkippable { + enumerationSkipped += 1 + trace.append("SEND_NEXT_DEVICE_CONFIG(116) → index=\(r.index) name did not decode — stepped over") + return true + } + if let key = r.key { + enumeratedKeys.append(key) + trace.append("SEND_NEXT_DEVICE_CONFIG(116) → index=\(r.index) key=\"\(key)\"") + } + return true + } + + /// Record one decoded VALUE reply. public mutating func noteReply(_ r: DeviceConfigReadProbe.ValueResponse, for step: Step) { setStatus(r.isUnsupported ? .unsupported : .answered, for: step.opcode) + if step.group == .crossNamespace { + if step.opcode == DeviceConfigReadProbe.getFeatureFlagValueCmd { + featureFlagVerbOnDeviceConfigKey = r.existence + } else if step.opcode == DeviceConfigReadProbe.getDeviceConfigValueCmd { + deviceConfigVerbOnFlagKey = r.existence + } + } let value = r.value(for: step.key) readings.append(Reading(group: step.group, opcode: step.opcode, key: step.key, value: value, - resultCode: r.resultCode, recordHex: r.recordHex)) + resultCode: r.resultCode, recordHex: r.recordHex, + derivation: step.derivation)) + // The candidate section lists every name it asked, so repeating a plain "unknown" in the + // transcript would double the report for no information. Anything else is always traced. + guard step.group != .candidate || r.existence != .unknown else { return } var line = "\(DeviceConfigReadProbeReport.opcodeLabel(step.opcode)) key=\"\(step.key)\"" if let c = r.resultCode { line += " → result=\(FeatureFlagProbe.resultLabel(c))(\(c))" } else { line += " →" } + line += " \(r.existence.label)" if let v = value { line += " value=\(DeviceConfigReadProbe.valueLabel(v))" } line += " record=[\(r.recordHex)]" trace.append(line) @@ -476,10 +635,16 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// A verb's status only ever moves off `untried`; a later step never upgrades a verdict already /// reached, so one lucky reply after an UNSUPPORTED cannot rewrite the headline. private mutating func setStatus(_ s: VerbStatus, for opcode: UInt8) { - if opcode == DeviceConfigReadProbe.getFeatureFlagValueCmd { + switch opcode { + case DeviceConfigReadProbe.getFeatureFlagValueCmd: if featureFlagVerb == .untried || featureFlagVerb == .answered { featureFlagVerb = s } - } else if opcode == DeviceConfigReadProbe.getDeviceConfigValueCmd { + case DeviceConfigReadProbe.getDeviceConfigValueCmd: if deviceConfigVerb == .untried || deviceConfigVerb == .answered { deviceConfigVerb = s } + case ConfigKeySweep.startDeviceConfigKeyExchangeCmd, ConfigKeySweep.sendNextDeviceConfigCmd: + if enumerationVerb == .untried || enumerationVerb == .answered { enumerationVerb = s } + if s != .answered { enumPhase = 2 } + default: + break } } @@ -487,12 +652,35 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// Short opcode label used in the transcript. static func opcodeLabel(_ opcode: UInt8) -> String { - opcode == DeviceConfigReadProbe.getDeviceConfigValueCmd - ? "GET_DEVICE_CONFIG_VALUE(121)" : "GET_FF_VALUE(128)" + switch opcode { + case DeviceConfigReadProbe.getDeviceConfigValueCmd: return "GET_DEVICE_CONFIG_VALUE(121)" + case ConfigKeySweep.startDeviceConfigKeyExchangeCmd: return "START_DEVICE_CONFIG_KEY_EXCHANGE(115)" + case ConfigKeySweep.sendNextDeviceConfigCmd: return "SEND_NEXT_DEVICE_CONFIG(116)" + default: return "GET_FF_VALUE(128)" + } + } + + /// Candidate readings only. + private var candidateReadings: [Reading] { readings.filter { $0.group == .candidate } } + + /// Every key name this run proved EXISTS that NOOP did not already have — the whole point of the + /// exercise. Enumerated names count; so does any candidate the oracle confirmed. + public var newKeysFound: [String] { + var out = enumeratedKeys.filter { + !knownFlagKeys.contains($0) && $0 != DeviceConfigReadProbe.deviceConfigDiscoveryKey + } + for r in candidateReadings where r.existence == .exists && !out.contains(r.key) { out.append(r.key) } + return out } /// One-line summary of what the probe established. public var verdict: String { + if !newKeysFound.isEmpty { + return "\(newKeysFound.count) config key name(s) found that NOOP did not have: \(newKeysFound.joined(separator: ", "))" + } + if enumerationVerb == .answered { + return "the strap enumerated its device-config namespace and returned no key NOOP did not already have" + } let answered = [featureFlagVerb, deviceConfigVerb].filter { $0 == .answered }.count if answered == 0 { let both = "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" @@ -504,42 +692,140 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { } return both } - let named = readings.filter { $0.value != nil }.count - if named == 0 { - return "\(answered) of 2 read verbs answered, but no reply echoed its key so no value is claimed" + let asked = candidateReadings.count + if asked == 0 { + return "\(answered) of 2 read verbs answered; no candidate name was asked" } - return "\(answered) of 2 read verbs answered; read \(named) config value(s)" + let unknown = candidateReadings.filter { $0.existence == .unknown }.count + if unknown == asked { + return "asked \(asked) candidate key name(s); this firmware has none of them (a clean negative)" + } + return "asked \(asked) candidate key name(s); \(unknown) do not exist, \(asked - unknown) inconclusive" } /// The full copyable report. public func render() -> String { let fam = family == .whoop5 ? "WHOOP 5/MG" : "WHOOP 4.0" - var sb = "#103 DEVICE-CONFIG READ PROBE — \(fam)\n" - sb += "Read-only: GET_DEVICE_CONFIG_VALUE(121) + GET_FF_VALUE(128). No value is written; " - sb += "SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path.\n" - sb += "Follow-up to the #761 enumeration probe: that one asked for key NAMES, this asks for VALUES.\n" + var sb = "#103 CONFIG KEY PROBE — \(fam)\n" + sb += "Read-only: START_DEVICE_CONFIG_KEY_EXCHANGE(115), SEND_NEXT_DEVICE_CONFIG(116), " + sb += "GET_DEVICE_CONFIG_VALUE(121), GET_FF_VALUE(128).\n" + sb += "No value is written; SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path.\n" + sb += "Oracle: result=SUCCESS(1) means the key NAME exists; result=FAILURE(0) means the firmware has no such key.\n" sb += "\nVerdict: \(verdict)\n" if let stopReason { sb += "Stopped: \(stopReason)\n" } - sb += "\nRead verbs:\n" - sb += " " + DeviceConfigReadProbe.padded("GET_FF_VALUE(128)", to: 30) + featureFlagVerb.rawValue + "\n" - sb += " " + DeviceConfigReadProbe.padded("GET_DEVICE_CONFIG_VALUE(121)", to: 30) + deviceConfigVerb.rawValue + "\n" + sb += "\nVerbs:\n" + sb += " " + DeviceConfigReadProbe.padded("device-config enumerate(115/116)", to: 34) + + enumerationVerb.rawValue + "\n" + sb += " " + DeviceConfigReadProbe.padded("GET_FF_VALUE(128)", to: 34) + featureFlagVerb.rawValue + "\n" + sb += " " + DeviceConfigReadProbe.padded("GET_DEVICE_CONFIG_VALUE(121)", to: 34) + + deviceConfigVerb.rawValue + "\n" + sb += enumerationSection() + sb += namespaceSection() sb += section(.discovery, - title: "Discovery — one round-trip per verb against a key it should know", + title: "Discovery — one round-trip per value verb against a key it should know", empty: "(none — no reply was decoded)") - sb += section(.knownFlag, - title: "Known feature-flag values (names NOOP already writes; values never read before)", - empty: "(none — the verb that would carry them did not answer)") - sb += section(.candidate, - title: "Candidate oxygen keys — GUESSES, never observed on a wire or in any table", - empty: "(none — the verb that would carry them did not answer)") + sb += section(.knownKey, + title: "Known key values (the flags NOOP writes, plus anything enumeration returned)", + empty: "(none — no value verb answered)") + sb += candidateSection() sb += "\nExchange:\n" for line in trace { sb += " " + line + "\n" } return sb } + /// The strap's own device-config key list — the result that makes guessing unnecessary. + private func enumerationSection() -> String { + var sb = "\nDevice-config keys the strap listed for itself (115/116) (\(enumeratedKeys.count)):\n" + if enumeratedKeys.isEmpty { + switch enumerationVerb { + case .unsupported: + sb += " (none — the firmware refused 115 as UNSUPPORTED)\n" + case .silent: + sb += " (none — no reply to 115)\n" + case .undecodable: + sb += " (none — the reply did not decode)\n" + case .answered: + sb += " (none — 115 answered but the walk produced no names)\n" + case .untried: + sb += " (none — not reached)\n" + } + return sb + } + for (i, key) in enumeratedKeys.enumerated() { + sb += String(format: " %2d. ", i + 1) + key + "\n" + } + if enumerationSkipped > 0 { + sb += " (\(enumerationSkipped) further entr(ies) the strap called real but whose name did not decode)\n" + } + if let c = enumeratedCount, c != enumeratedKeys.count + enumerationSkipped { + sb += " (the strap announced \(c); the walk served \(enumeratedKeys.count + enumerationSkipped))\n" + } + return sb + } + + /// Whether the two namespaces are really separate — two round-trips that shape every future sweep. + private func namespaceSection() -> String { + var sb = "\nNamespace separation:\n" + let ffLabel = featureFlagVerbOnDeviceConfigKey?.label ?? "not asked" + let dcLabel = deviceConfigVerbOnFlagKey?.label ?? "not asked" + sb += " " + DeviceConfigReadProbe.padded("128 asked for a device-config key", to: 38) + ffLabel + "\n" + sb += " " + DeviceConfigReadProbe.padded("121 asked for a feature-flag key", to: 38) + dcLabel + "\n" + switch (featureFlagVerbOnDeviceConfigKey, deviceConfigVerbOnFlagKey) { + case (.some(.exists), _): + sb += " ⇒ GET_FF_VALUE(128) serves BOTH namespaces.\n" + case (_, .some(.exists)): + sb += " ⇒ GET_DEVICE_CONFIG_VALUE(121) serves BOTH namespaces.\n" + case (.some(.unknown), .some(.unknown)): + sb += " ⇒ the namespaces are separate: neither verb sees the other's keys.\n" + default: + sb += " ⇒ inconclusive.\n" + } + return sb + } + + /// The candidate sweep, grouped by derivation, with the tested/untested arithmetic spelled out. + private func candidateSection() -> String { + let rows = candidateReadings + let tested = rows.count + let total = ConfigKeySweep.catalogue.count + let untested = total - batch.start - tested + var sb = "\nCandidate key names — GUESSES, never observed on a wire or in any table" + sb += " (\(tested) asked of \(total) in the catalogue" + sb += untested > 0 ? "; \(untested) untested" : "; none untested" + sb += "):\n" + if rows.isEmpty { + if !enumeratedKeys.isEmpty { + sb += " (skipped — the strap enumerated its own device-config keys, so nothing needs guessing)\n" + } else if featureFlagVerb != .answered && deviceConfigVerb != .answered { + sb += " (none — no value verb answered, so no name could be asked)\n" + } else { + sb += " (none asked)\n" + } + return sb + } + let exists = rows.filter { $0.existence == .exists }.count + let unknown = rows.filter { $0.existence == .unknown }.count + sb += " \(exists) exist · \(unknown) do not · \(tested - exists - unknown) inconclusive\n" + for derivation in ConfigKeySweep.Derivation.allCases { + let group = rows.filter { $0.derivation == derivation } + guard !group.isEmpty else { continue } + sb += "\n \(derivation.title) (\(group.count)):\n" + for (i, r) in group.enumerated() { + sb += String(format: " %2d. ", i + 1) + DeviceConfigReadProbe.padded(r.key, to: 32) + + r.existence.label + if let v = r.value { sb += " = " + DeviceConfigReadProbe.valueLabel(v) } + sb += "\n" + } + } + if untested > 0 { + sb += "\n Run the probe again to continue from catalogue entry \(batch.nextCursor + 1).\n" + } + return sb + } + /// One rendered section of readings. private func section(_ group: Group, title: String, empty: String) -> String { let rows = readings.filter { $0.group == group } diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/FeatureFlagProbe.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/FeatureFlagProbe.swift index 04b15e77fc..c25a0f3457 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/FeatureFlagProbe.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/FeatureFlagProbe.swift @@ -148,8 +148,15 @@ public enum FeatureFlagProbe { /// Decode a `START_FF_KEY_EXCHANGE` COMMAND_RESPONSE. CRC-gated: a frame whose checksums fail is /// rejected before any field is read. - public static func parseStart(frame: [UInt8], family: DeviceFamily) -> Result { - switch record(frame: frame, family: family, expecting: startKeyExchangeCmd) { + /// + /// `expecting` defaults to 117 and exists so #103's sweep can reuse this decoder for the DEVICE-CONFIG + /// twin `START_DEVICE_CONFIG_KEY_EXCHANGE` (115). That reuse ASSUMES the two share a record layout — an + /// inference from the naming symmetry in this repo's own `CommandNumber` table, not an observation. It + /// fails closed: a mismatch surfaces as `.truncated` or as a count `countIsPlausible` rejects. + public static func parseStart(frame: [UInt8], family: DeviceFamily, + expecting: UInt8 = startKeyExchangeCmd) + -> Result { + switch record(frame: frame, family: family, expecting: expecting) { case .failure(let f): return .failure(f) case .success(let r): guard r.record.count >= 3 else { return .failure(.truncated) } @@ -159,8 +166,12 @@ public enum FeatureFlagProbe { } /// Decode a `SEND_NEXT_FF` COMMAND_RESPONSE. CRC-gated like `parseStart`. - public static func parseNext(frame: [UInt8], family: DeviceFamily) -> Result { - switch record(frame: frame, family: family, expecting: sendNextFlagCmd) { + /// `expecting` defaults to 118 and carries the same reuse contract documented on `parseStart`: #103's + /// sweep passes 116 (`SEND_NEXT_DEVICE_CONFIG`) to walk the device-config namespace. + public static func parseNext(frame: [UInt8], family: DeviceFamily, + expecting: UInt8 = sendNextFlagCmd) + -> Result { + switch record(frame: frame, family: family, expecting: expecting) { case .failure(let f): return .failure(f) case .success(let r): // revision + index are the minimum: the 0xFF end marker arrives with nothing after it. diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ConfigKeySweepTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ConfigKeySweepTests.swift new file mode 100644 index 0000000000..c043346157 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/ConfigKeySweepTests.swift @@ -0,0 +1,254 @@ +import XCTest +@testable import WhoopProtocol + +/// #103: the key-existence ORACLE, the candidate catalogue's derivation discipline, and the batching +/// arithmetic that makes a catalogue larger than one run's budget truncate visibly instead of silently. +/// +/// The catalogue is data, so these tests are about its INVARIANTS — no duplicates, nothing already known, +/// nothing already ruled out, nothing that cannot survive the 32-byte wire field — plus a golden string +/// the Kotlin twin asserts byte-for-byte so the two lists cannot drift. +final class ConfigKeySweepTests: XCTestCase { + + private var flagKeys: [String] { Whoop5Config.enableR22Sequence.map(\.name) } + + // MARK: - The oracle + + func testOracleReadsTheResultCodeAndNothingElse() { + XCTAssertEqual(ConfigKeySweep.existence(resultCode: 1), .exists) // SUCCESS + XCTAssertEqual(ConfigKeySweep.existence(resultCode: 0), .unknown) // FAILURE + XCTAssertEqual(ConfigKeySweep.existence(resultCode: 2), .inconclusive) // PENDING + XCTAssertEqual(ConfigKeySweep.existence(resultCode: 3), .inconclusive) // UNSUPPORTED + XCTAssertEqual(ConfigKeySweep.existence(resultCode: 9), .inconclusive) + } + + /// WHOOP 4.0 carries no labelled result code here, so the oracle must decline rather than read the + /// absence as "the key does not exist". + func testAbsentResultCodeIsInconclusiveNotUnknown() { + XCTAssertEqual(ConfigKeySweep.existence(resultCode: nil), .inconclusive) + XCTAssertNotEqual(ConfigKeySweep.existence(resultCode: nil), .unknown) + } + + func testExistenceLabelsAreStableAcrossPlatforms() { + XCTAssertEqual(ConfigKeySweep.Existence.exists.label, "exists") + XCTAssertEqual(ConfigKeySweep.Existence.unknown.label, "unknown") + XCTAssertEqual(ConfigKeySweep.Existence.inconclusive.label, "inconclusive") + } + + // MARK: - Enumeration verbs + + func testEnumerationOpcodesAreTheDeviceConfigPair() { + XCTAssertEqual(ConfigKeySweep.startDeviceConfigKeyExchangeCmd, 115) // 0x73 + XCTAssertEqual(ConfigKeySweep.sendNextDeviceConfigCmd, 116) // 0x74 + // The body is the bare inner b3 byte, exactly as the 117/118 pair sends it. + XCTAssertEqual(ConfigKeySweep.enumerationRequestBody, [0x01]) + } + + // MARK: - Catalogue invariants + + func testCatalogueHasNoDuplicateNames() { + let keys = ConfigKeySweep.catalogue.map(\.key) + XCTAssertEqual(Set(keys).count, keys.count, "a duplicate spends a round-trip for no information") + } + + /// A candidate that NOOP already writes is not a candidate — it is a known key, and asking it in the + /// candidate phase would inflate an "exists" count with something the probe already knew. + func testCatalogueNeverRepeatsAKeyNOOPAlreadyWrites() { + for c in ConfigKeySweep.catalogue { + XCTAssertFalse(flagKeys.contains(c.key), "\(c.key) is already in enableR22Sequence") + XCTAssertNotEqual(c.key, DeviceConfigReadProbe.deviceConfigDiscoveryKey) + } + } + + /// The eight plain-English oxygen names already came back FAILURE on a real strap. Re-asking them + /// would spend round-trips to re-learn a known negative. + func testCatalogueNeverRepeatsARetiredName() { + for c in ConfigKeySweep.catalogue { + XCTAssertFalse(ConfigKeySweep.retiredKeys.contains(c.key), + "\(c.key) already answered FAILURE — it belongs in retiredKeys, not the catalogue") + } + XCTAssertEqual(Set(ConfigKeySweep.retiredKeys).count, ConfigKeySweep.retiredKeys.count) + } + + /// Names are TRUNCATED to 32 bytes on the wire, not rejected, so two candidates sharing a 32-byte + /// prefix would be indistinguishable — and a name longer than the field could never match anyway. + func testEveryCandidateFitsTheWireNameField() { + for c in ConfigKeySweep.catalogue { + let bytes = Array(c.key.utf8) + XCTAssertLessThanOrEqual(bytes.count, DeviceConfigReadProbe.nameFieldBytes, "\(c.key) is too long") + XCTAssertFalse(bytes.isEmpty) + for b in bytes { + XCTAssertTrue((97...122).contains(b) || (48...57).contains(b) || b == 95, + "\(c.key) is not lowercase snake_case, which every confirmed key is") + } + } + } + + /// The derivation is the product: a candidate whose family is unexplained is a guess with no argument + /// behind it, and a negative result on it rules nothing out. + func testEveryDerivationIsUsedAndTitled() { + for d in ConfigKeySweep.Derivation.allCases { + XCTAssertTrue(ConfigKeySweep.catalogue.contains { $0.derivation == d }, + "\(d.rawValue) has a title but no candidates") + XCTAssertFalse(d.title.isEmpty) + } + } + + /// Only the `whoop_…` family belongs to the device-config namespace — that prefix is the one shape a + /// confirmed device-config key has. + func testNamespaceAssignmentFollowsTheOnlyConfirmedDeviceConfigShape() { + for c in ConfigKeySweep.catalogue { + if c.namespace == .deviceConfig { + XCTAssertTrue(c.key.hasPrefix("whoop_"), "\(c.key) is asked of 121 but is not whoop_-shaped") + } else { + XCTAssertFalse(c.key.hasPrefix("whoop_"), "\(c.key) is whoop_-shaped but asked of 128") + } + } + } + + /// The single highest-value entry: v7 is the hole in an OBSERVED contiguous series, so a SUCCESS on it + /// would prove the oracle finds keys NOOP does not already know. + func testTheObservedSeriesHoleIsInTheCatalogue() { + let keys = ConfigKeySweep.catalogue.map(\.key) + XCTAssertTrue(keys.contains("enable_r22_v7_packets")) + // …and it is genuinely a hole: NOOP writes v2…v6 and v8, never v7. + XCTAssertFalse(flagKeys.contains("enable_r22_v7_packets")) + XCTAssertTrue(flagKeys.contains("enable_r22_v6_packets")) + XCTAssertTrue(flagKeys.contains("enable_r22_v8_packets")) + } + + // MARK: - Batching + + func testTodaysCatalogueFitsInOneRun() { + XCTAssertLessThanOrEqual(ConfigKeySweep.catalogue.count, ConfigKeySweep.maxKeysPerRun) + let b = ConfigKeySweep.batch(from: 0) + XCTAssertEqual(b.start, 0) + XCTAssertEqual(b.candidates.count, ConfigKeySweep.catalogue.count) + XCTAssertEqual(b.remaining, 0) + XCTAssertTrue(b.completesCatalogue) + XCTAssertEqual(b.nextCursor, 0, "a completed catalogue restarts the next run at the top") + } + + /// The property the sweep exists to guarantee: a catalogue larger than one run's budget is truncated + /// VISIBLY (`remaining` is non-zero) and resumed from `nextCursor`, never silently cut. + func testAnOversizeCatalogueTruncatesVisiblyAndResumes() { + let total = ConfigKeySweep.catalogue.count + let first = ConfigKeySweep.batch(from: 0, limit: 10) + XCTAssertEqual(first.candidates.count, 10) + XCTAssertEqual(first.start, 0) + XCTAssertEqual(first.remaining, total - 10) + XCTAssertFalse(first.completesCatalogue) + XCTAssertEqual(first.nextCursor, 10) + + let second = ConfigKeySweep.batch(from: first.nextCursor, limit: 10) + XCTAssertEqual(second.start, 10) + XCTAssertEqual(second.candidates.first?.key, ConfigKeySweep.catalogue[10].key) + XCTAssertEqual(second.remaining, total - 20) + + // Walk to the end: the union of every slice is the whole catalogue, each name exactly once. + var seen: [String] = [] + var cursor = 0 + repeat { + let b = ConfigKeySweep.batch(from: cursor, limit: 10) + seen += b.candidates.map(\.key) + cursor = b.nextCursor + } while cursor != 0 + XCTAssertEqual(seen, ConfigKeySweep.catalogue.map(\.key)) + } + + func testAStaleOrNonsenseCursorRestartsRatherThanWastingARun() { + XCTAssertEqual(ConfigKeySweep.batch(from: -1).start, 0) + XCTAssertEqual(ConfigKeySweep.batch(from: 10_000).start, 0) + XCTAssertEqual(ConfigKeySweep.batch(from: ConfigKeySweep.catalogue.count).start, 0) + XCTAssertFalse(ConfigKeySweep.batch(from: -1).candidates.isEmpty) + } + + /// A slice never wraps mid-batch, so one run can never ask the same name twice. + func testASliceNeverWrapsWithinOneRun() { + let b = ConfigKeySweep.batch(from: ConfigKeySweep.catalogue.count - 3, limit: 10) + XCTAssertEqual(b.candidates.count, 3) + XCTAssertEqual(b.nextCursor, 0) + XCTAssertEqual(Set(b.candidates.map(\.key)).count, b.candidates.count) + } + + // MARK: - Cross-platform lockstep + + /// The catalogue is duplicated in Kotlin by hand, so pin it as one string the Kotlin twin asserts + /// byte-for-byte. A name added on one platform and not the other fails HERE, not on a user's strap. + func testCatalogueIsPinnedForTheKotlinTwin() { + let pinned = ConfigKeySweep.catalogue + .map { "\($0.derivation.rawValue):\($0.namespace.rawValue):\($0.key)" } + .joined(separator: "\n") + XCTAssertEqual(pinned, ConfigKeySweepTests.goldenCatalogue) + XCTAssertEqual(ConfigKeySweep.catalogue.count, 54) + XCTAssertEqual(ConfigKeySweep.retiredKeys.joined(separator: "\n"), + ConfigKeySweepTests.goldenRetired) + } + + static let goldenCatalogue = """ + sigSeries:featureFlag:enable_sig1 + sigSeries:featureFlag:enable_sig2 + sigSeries:featureFlag:enable_sig3 + sigSeries:featureFlag:enable_sig4 + sigSeries:featureFlag:enable_sig5 + sigSeries:featureFlag:enable_sig6 + sigSeries:featureFlag:enable_sig7 + sigSeries:featureFlag:enable_sig8 + sigSeries:featureFlag:enable_sig9 + sigSeries:featureFlag:enable_sig10 + sigSeries:featureFlag:enable_sig13 + sigSeries:featureFlag:enable_sig14 + sigSeries:featureFlag:enable_sig15 + sigSeries:featureFlag:enable_sig16 + sigSeries:featureFlag:enable_sig11 + sigSeries:featureFlag:enable_sig12_during_sleep + r22VersionGaps:featureFlag:enable_r22_v1_packets + r22VersionGaps:featureFlag:enable_r22_v7_packets + r22VersionGaps:featureFlag:enable_r22_v9_packets + r22VersionGaps:featureFlag:enable_r22_v10_packets + revisionSlot:featureFlag:enable_r7_packets + revisionSlot:featureFlag:enable_r10_packets + revisionSlot:featureFlag:enable_r11_packets + revisionSlot:featureFlag:enable_r16_packets + revisionSlot:featureFlag:enable_r17_packets + revisionSlot:featureFlag:enable_r20_packets + revisionSlot:featureFlag:enable_r21_packets + revisionSlot:featureFlag:enable_pip_r26_packets + opticalAfe:featureFlag:enable_optical_data + opticalAfe:featureFlag:enable_optical_packets + opticalAfe:featureFlag:make_optical_visible + opticalAfe:featureFlag:enable_afe_packets + opticalAfe:featureFlag:red_hw_switching + opticalAfe:featureFlag:green_hw_switching + labradorEcg:featureFlag:enable_labrador_packets + labradorEcg:featureFlag:enable_labrador_raw_save + labradorEcg:featureFlag:enable_labrador_filtered + labradorEcg:featureFlag:make_labrador_visible + labradorEcg:featureFlag:enable_ecg_packets + researchHighRate:featureFlag:enable_research_packets + researchHighRate:featureFlag:make_research_visible + researchHighRate:featureFlag:enable_raw_packets + researchHighRate:featureFlag:enable_hrfm_packets + sigprocOxygen:featureFlag:make_spo2_visible + sigprocOxygen:featureFlag:enable_spo2_during_sleep + sigprocOxygen:featureFlag:enable_spo2_gen5 + sigprocOxygen:featureFlag:spo2_ch_switching + sigprocOxygen:featureFlag:disable_spo2_packets + sigprocOxygen:featureFlag:enable_sigproc_spo2 + sigprocOxygen:featureFlag:sigproc_spo2_during_sleep + deviceConfigNamespace:deviceConfig:whoop_live_hrv_in_adv_ind_pkt + deviceConfigNamespace:deviceConfig:whoop_live_spo2_in_adv_ind_pkt + deviceConfigNamespace:deviceConfig:whoop_live_temp_in_adv_ind_pkt + deviceConfigNamespace:deviceConfig:whoop_live_ecg_in_adv_ind_pkt + """ + + static let goldenRetired = """ + enable_spo2 + enable_spo2_packets + spo2_enable + enable_blood_oxygen + blood_oxygen_enable + enable_pulse_ox + enable_oxygen_packets + spo2_subscription_enabled + """ +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift index b2382e0161..408a4241ec 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift @@ -53,12 +53,15 @@ final class DeviceConfigReadProbeTests: XCTestCase { // MARK: - The read-only allowlist (the hard safety constraint) - func testAllowlistAdmitsOnlyTheTwoReadVerbs() { - XCTAssertEqual(DeviceConfigReadProbe.getDeviceConfigValueCmd, 121) // 0x79 - XCTAssertEqual(DeviceConfigReadProbe.getFeatureFlagValueCmd, 128) // 0x80 - XCTAssertEqual(DeviceConfigReadProbe.readOnlyOpcodes, [121, 128]) - XCTAssertTrue(DeviceConfigReadProbe.isReadOnlyOpcode(121)) - XCTAssertTrue(DeviceConfigReadProbe.isReadOnlyOpcode(128)) + func testAllowlistAdmitsOnlyTheFourReadVerbs() { + XCTAssertEqual(DeviceConfigReadProbe.getDeviceConfigValueCmd, 121) // 0x79 + XCTAssertEqual(DeviceConfigReadProbe.getFeatureFlagValueCmd, 128) // 0x80 + XCTAssertEqual(ConfigKeySweep.startDeviceConfigKeyExchangeCmd, 115) // 0x73 + XCTAssertEqual(ConfigKeySweep.sendNextDeviceConfigCmd, 116) // 0x74 + XCTAssertEqual(DeviceConfigReadProbe.readOnlyOpcodes, [115, 116, 121, 128]) + for op in DeviceConfigReadProbe.readOnlyOpcodes { + XCTAssertTrue(DeviceConfigReadProbe.isReadOnlyOpcode(op)) + } } /// The load-bearing safety test: the two config WRITE verbs must be rejected by the same predicate @@ -76,16 +79,21 @@ final class DeviceConfigReadProbeTests: XCTestCase { XCTAssertTrue(DeviceConfigReadProbe.readOnlyOpcodes.isDisjoint(with: DeviceConfigReadProbe.writeOpcodes)) } - /// Nothing outside the pair passes either — including the enumerate verbs #761 owns and the - /// destructive opcodes that must never come near this path. + /// Nothing outside the four passes either — including the feature-flag enumerate verbs #872 owns + /// (they have their own probe and their own gate) and the destructive opcodes that must never come + /// near this path. func testAllowlistRejectsEveryOtherOpcode() { + var rejected = 0 for op in UInt8.min...UInt8.max where !DeviceConfigReadProbe.readOnlyOpcodes.contains(op) { XCTAssertFalse(DeviceConfigReadProbe.isReadOnlyOpcode(op), "opcode \(op) must not pass") + rejected += 1 } + XCTAssertEqual(rejected, 252, "four admitted, every other opcode rejected") XCTAssertFalse(DeviceConfigReadProbe.isReadOnlyOpcode(FeatureFlagProbe.startKeyExchangeCmd)) XCTAssertFalse(DeviceConfigReadProbe.isReadOnlyOpcode(FeatureFlagProbe.sendNextFlagCmd)) XCTAssertFalse(DeviceConfigReadProbe.isReadOnlyOpcode(25)) // FORCE_TRIM XCTAssertFalse(DeviceConfigReadProbe.isReadOnlyOpcode(29)) // REBOOT_STRAP + XCTAssertFalse(DeviceConfigReadProbe.isReadOnlyOpcode(32)) // POWER_CYCLE_STRAP } // MARK: - Request body @@ -213,233 +221,379 @@ final class DeviceConfigReadProbeTests: XCTestCase { .failure(.truncated)) } - // MARK: - The plan - func testDiscoveryTriesEachVerbOnceBeforeAnythingElse() { - var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, - candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) - guard let first = report.nextStep() else { return XCTFail("expected a first step") } - XCTAssertEqual(first.opcode, 128) - XCTAssertEqual(first.key, "enable_r22_packets", "128 is discovered against a flag NOOP writes") - XCTAssertEqual(first.group, .discovery) + // MARK: - Enumeration frame builders (the 117/118 record layouts, reused for 115/116) - // Nothing is known yet, so the second step is the other verb, not a value read. - guard let second = report.nextStep() else { return XCTFail("expected a second step") } - XCTAssertEqual(second.opcode, 121) - XCTAssertEqual(second.key, DeviceConfigReadProbe.deviceConfigDiscoveryKey) - XCTAssertEqual(second.group, .discovery) + /// `START_DEVICE_CONFIG_KEY_EXCHANGE` reply: record = [revision][count u16 LE]. + private func enumStart(result: UInt8, revision: UInt8, count: UInt16) -> [UInt8] { + whoop5Response(cmd: ConfigKeySweep.startDeviceConfigKeyExchangeCmd, + payload: payload(result: result, + record: [revision, UInt8(count & 0xFF), UInt8(count >> 8)])) } - func testBothVerbsUnsupportedEndsTheProbeAfterTwoRoundTrips() { - var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, - candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) - for _ in 0..<2 { - guard let step = report.nextStep() else { return XCTFail("expected a discovery step") } - let frame = whoop5Response(cmd: step.opcode, payload: payload(result: 3, record: [0x00, 0x00, 0x00])) - guard case .success(let r) = DeviceConfigReadProbe.parse(frame: frame, family: .whoop5, - expecting: step.opcode) else { - return XCTFail("parse") - } - report.noteReply(r, for: step) + /// `SEND_NEXT_DEVICE_CONFIG` reply: record = [revision][index][validKey][key ASCII NUL-terminated]. + private func enumNext(index: UInt8, key: String?, validKey: Bool = true, + result: UInt8 = 1) -> [UInt8] { + var record: [UInt8] = [0x0A, index, validKey ? 1 : 0] + if let key { record += Array(key.utf8) + [0] } + return whoop5Response(cmd: ConfigKeySweep.sendNextDeviceConfigCmd, + payload: payload(result: result, record: record)) + } + + private func startReply(_ frame: [UInt8]) -> FeatureFlagProbe.StartResponse { + guard case .success(let r) = FeatureFlagProbe.parseStart( + frame: frame, family: .whoop5, + expecting: ConfigKeySweep.startDeviceConfigKeyExchangeCmd) else { + fatalError("fixture did not decode") } - XCTAssertNil(report.nextStep(), "a refused verb must not drive sixteen more round-trips") - XCTAssertEqual(report.steps, 2) - XCTAssertEqual(report.featureFlagVerb, .unsupported) - XCTAssertEqual(report.deviceConfigVerb, .unsupported) - XCTAssertTrue(report.verdict.contains("rejected as UNSUPPORTED")) - XCTAssertTrue(report.render().contains("neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121)")) + return r } - func testSilentVerbsEndTheProbeAndAreSaidPlainly() { - var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, - candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) - for _ in 0..<2 { - guard let step = report.nextStep() else { return XCTFail("expected a discovery step") } - report.noteTimeout(for: step, seconds: 8) + private func nextReply(_ frame: [UInt8]) -> FeatureFlagProbe.NextResponse { + guard case .success(let r) = FeatureFlagProbe.parseNext( + frame: frame, family: .whoop5, + expecting: ConfigKeySweep.sendNextDeviceConfigCmd) else { + fatalError("fixture did not decode") + } + return r + } + + /// A two-flag report with a two-name candidate slice — small enough to drive step by step. + private func smallReport(limit: Int = 2) -> DeviceConfigReadProbeReport { + DeviceConfigReadProbeReport(family: .whoop5, + knownFlagKeys: ["enable_r22_packets", "hr_ch_switching"], + batch: ConfigKeySweep.batch(from: 0, limit: limit)) + } + + // MARK: - The plan: enumerate first, guess last + + /// The whole point of the restructure: nothing is guessed until the strap has been asked to list its + /// own keys. + func testTheProbeAsksTheStrapToEnumerateBeforeItGuessesAnything() { + var report = smallReport() + guard let first = report.nextStep() else { return XCTFail("no first step") } + XCTAssertEqual(first.opcode, ConfigKeySweep.startDeviceConfigKeyExchangeCmd) + XCTAssertEqual(first.group, .enumerate) + XCTAssertNil(first.derivation) + } + + /// If the strap lists its own device-config keys there is nothing left to guess, so the sweep is + /// skipped entirely rather than spending round-trips on names the answer already covers. + func testAnAnsweringEnumerationSkipsTheGuessedSweepEntirely() { + var report = smallReport() + guard let s1 = report.nextStep() else { return XCTFail("s1") } + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 2))) + XCTAssertEqual(s1.opcode, 115) + + guard let s2 = report.nextStep() else { return XCTFail("s2") } + XCTAssertEqual(s2.opcode, ConfigKeySweep.sendNextDeviceConfigCmd) + XCTAssertTrue(report.noteEnumerationNext(nextReply(enumNext(index: 1, key: "whoop_live_hr_in_adv_ind_pkt")))) + + guard report.nextStep() != nil else { return XCTFail("s3") } + XCTAssertTrue(report.noteEnumerationNext(nextReply(enumNext(index: 2, key: "whoop_sleep_coach_enabled")))) + + guard report.nextStep() != nil else { return XCTFail("s4") } + XCTAssertFalse(report.noteEnumerationNext(nextReply(enumNext(index: 0xFF, key: nil, validKey: false)))) + + XCTAssertEqual(report.enumeratedKeys, ["whoop_live_hr_in_adv_ind_pkt", "whoop_sleep_coach_enabled"]) + XCTAssertEqual(report.enumerationVerb, .answered) + // The Broadcast-HR key is one NOOP already writes, so only the second name is NEW. + + // Drive the rest of the plan; nothing may ever be a candidate step. + var guard_ = 0 + while let step = report.nextStep(), guard_ < 200 { + guard_ += 1 + XCTAssertNotEqual(step.group, .candidate, "the sweep must not run once enumeration answered") + report.noteReply(.init(resultCode: 1, record: echoRecord(step.key, value: 0x32)), for: step) + } + XCTAssertTrue(report.render().contains("skipped — the strap enumerated its own device-config keys")) + XCTAssertEqual(report.newKeysFound, ["whoop_sleep_coach_enabled"]) + XCTAssertTrue(report.verdict.hasPrefix("1 config key name(s) found that NOOP did not have")) + } + + /// The #874 discipline, inherited: the strap's own end marker stops the walk, but a name OUR parser + /// declines is counted and stepped over — one bad entry must not throw away every key after it. + func testAnUndecodableNameIsSteppedOverRatherThanEndingTheWalk() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 3))) + _ = report.nextStep() + // validKey = 1 but the name bytes are not printable ASCII, so `key` is nil: skippable, not the end. + XCTAssertTrue(report.noteEnumerationNext( + FeatureFlagProbe.NextResponse(resultCode: 1, revision: 10, index: 1, validKey: true, key: nil))) + _ = report.nextStep() + XCTAssertTrue(report.noteEnumerationNext(nextReply(enumNext(index: 2, key: "whoop_after_the_bad_one")))) + XCTAssertEqual(report.enumeratedKeys, ["whoop_after_the_bad_one"]) + XCTAssertEqual(report.enumerationSkipped, 1) + } + + /// A refused enumeration is the case the guessing fallback exists for — and it must cost exactly one + /// round-trip, not one per key. + func testAnUnsupportedEnumerationCostsOneRoundTripAndOpensTheFallback() { + var report = smallReport() + guard let s1 = report.nextStep() else { return XCTFail("s1") } + XCTAssertEqual(s1.opcode, 115) + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) + XCTAssertEqual(report.enumerationVerb, .unsupported) + + guard let s2 = report.nextStep() else { return XCTFail("s2") } + XCTAssertEqual(s2.opcode, DeviceConfigReadProbe.getFeatureFlagValueCmd, + "116 must not be asked once 115 refused") + XCTAssertEqual(s2.group, .discovery) + } + + /// A silent enumeration retires the pair after ONE timeout rather than one per entry. + func testASilentEnumerationRetiresAfterOneTimeout() { + var report = smallReport() + guard let s1 = report.nextStep() else { return XCTFail("s1") } + report.noteTimeout(for: s1, seconds: 8) + XCTAssertEqual(report.enumerationVerb, .silent) + guard let s2 = report.nextStep() else { return XCTFail("s2") } + XCTAssertEqual(s2.group, .discovery) + XCTAssertTrue(report.render().contains("(none — no reply to 115)")) + } + + func testAnUndecodableEnumerationReplyRetiresIt() { + var report = smallReport() + guard let s1 = report.nextStep() else { return XCTFail("s1") } + report.noteFailure(.crc, for: s1) + XCTAssertEqual(report.enumerationVerb, .undecodable) + guard let s2 = report.nextStep() else { return XCTFail("s2") } + XCTAssertEqual(s2.group, .discovery) + XCTAssertEqual(report.stopReason, "CRC failed — frame rejected (never decoded)") + } + + // MARK: - Cross-namespace + + /// Two round-trips that settle whether the namespaces are separate — the result shapes every later + /// sweep, so it is asked of each verb that answered. + func testCrossNamespaceIsAskedOfEachAnsweringVerb() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) + + guard let d1 = report.nextStep() else { return XCTFail("d1") } + report.noteReply(.init(resultCode: 1, record: echoRecord(d1.key, value: 0x32)), for: d1) + guard let d2 = report.nextStep() else { return XCTFail("d2") } + report.noteReply(.init(resultCode: 1, record: echoRecord(d2.key, value: 0x30)), for: d2) + + guard let x1 = report.nextStep() else { return XCTFail("x1") } + XCTAssertEqual(x1.group, .crossNamespace) + XCTAssertEqual(x1.opcode, DeviceConfigReadProbe.getFeatureFlagValueCmd) + XCTAssertEqual(x1.key, DeviceConfigReadProbe.deviceConfigDiscoveryKey) + report.noteReply(.init(resultCode: 0, record: []), for: x1) + + guard let x2 = report.nextStep() else { return XCTFail("x2") } + XCTAssertEqual(x2.group, .crossNamespace) + XCTAssertEqual(x2.opcode, DeviceConfigReadProbe.getDeviceConfigValueCmd) + XCTAssertEqual(x2.key, "enable_r22_packets") + report.noteReply(.init(resultCode: 0, record: []), for: x2) + + XCTAssertEqual(report.featureFlagVerbOnDeviceConfigKey, .unknown) + XCTAssertEqual(report.deviceConfigVerbOnFlagKey, .unknown) + XCTAssertTrue(report.render().contains("the namespaces are separate")) + } + + /// If one verb turns out to serve both namespaces, everything afterwards goes through it — halving + /// the work every future sweep needs, on evidence gathered in the same run. + func testAVerbShownToServeBothNamespacesCarriesEverythingAfterwards() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) + guard let d1 = report.nextStep() else { return XCTFail("d1") } + report.noteReply(.init(resultCode: 1, record: echoRecord(d1.key, value: 0x32)), for: d1) + guard let d2 = report.nextStep() else { return XCTFail("d2") } + report.noteReply(.init(resultCode: 1, record: echoRecord(d2.key, value: 0x30)), for: d2) + guard let x1 = report.nextStep() else { return XCTFail("x1") } + report.noteReply(.init(resultCode: 0, record: []), for: x1) + guard let x2 = report.nextStep() else { return XCTFail("x2") } + // 121 DOES see a feature-flag key. + report.noteReply(.init(resultCode: 1, record: echoRecord(x2.key, value: 0x32)), for: x2) + XCTAssertEqual(report.deviceConfigVerbOnFlagKey, .exists) + + guard let k1 = report.nextStep() else { return XCTFail("k1") } + XCTAssertEqual(k1.opcode, DeviceConfigReadProbe.getDeviceConfigValueCmd, + "the verb proved to serve both namespaces carries the rest of the plan") + XCTAssertTrue(report.render().contains("GET_DEVICE_CONFIG_VALUE(121) serves BOTH namespaces.")) + } + + // MARK: - The sweep + + /// A fully-negative sweep is a RESULT, and the verdict must say so rather than reading like a failure. + func testAFullyNegativeSweepIsACleanNegativeVerdict() { + var (report, first) = driveToCandidates(limit: 2) + guard var step: DeviceConfigReadProbeReport.Step = first else { return XCTFail("no candidate") } + var asked: [String] = [] + while true { + XCTAssertEqual(step.group, .candidate) + asked.append(step.key) + report.noteReply(.init(resultCode: 0, record: []), for: step) + guard let next = report.nextStep() else { break } + step = next + } + XCTAssertEqual(asked, ["enable_sig1", "enable_sig2"]) + XCTAssertEqual(report.verdict, + "asked 2 candidate key name(s); this firmware has none of them (a clean negative)") + XCTAssertTrue(report.newKeysFound.isEmpty) + } + + /// And a hit is the headline, named in the verdict so a strap log's first line carries the finding. + func testACandidateThatExistsBecomesTheHeadline() { + var (report, first) = driveToCandidates(limit: 2) + guard let c1 = first else { return XCTFail("c1") } + report.noteReply(.init(resultCode: 1, record: echoRecord(c1.key, value: 0x31)), for: c1) + guard let c2 = report.nextStep() else { return XCTFail("c2") } + report.noteReply(.init(resultCode: 0, record: []), for: c2) + XCTAssertEqual(report.newKeysFound, ["enable_sig1"]) + XCTAssertEqual(report.verdict, + "1 config key name(s) found that NOOP did not have: enable_sig1") + // A hit is always traced in full, unlike a plain "unknown". + XCTAssertTrue(report.trace.contains { $0.contains("enable_sig1") && $0.contains("exists") }) + XCTAssertFalse(report.trace.contains { $0.contains("enable_sig2") }) + } + + /// No silent truncation: the report states how many names it asked, how many the catalogue holds, and + /// how many remain untested, plus where the next run resumes. + func testTheReportStatesTestedAndUntestedCountsAndWhereToResume() { + var (report, first) = driveToCandidates(limit: 2) + var step = first + while let s = step { + report.noteReply(.init(resultCode: 0, record: []), for: s) + step = report.nextStep() } - XCTAssertNil(report.nextStep()) - XCTAssertEqual(report.featureFlagVerb, .silent) - XCTAssertEqual(report.deviceConfigVerb, .silent) let text = report.render() - XCTAssertTrue(text.contains("no reply to either")) - XCTAssertTrue(text.contains("no COMMAND_RESPONSE within 8s")) - XCTAssertTrue(text.contains("(none — the verb that would carry them did not answer)")) + let total = ConfigKeySweep.catalogue.count + XCTAssertTrue(text.contains("(2 asked of \(total) in the catalogue; \(total - 2) untested)"), text) + XCTAssertTrue(text.contains("Run the probe again to continue from catalogue entry 3."), text) } - func testAnAnsweringFeatureFlagVerbWalksTheFifteenRemainingKnownFlags() { + /// The default catalogue is smaller than one run's budget, so a real run reports nothing untested. + func testAFullRunOfTodaysCatalogueLeavesNothingUntested() { var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, - candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) - // 128 answers; 121 is refused. - guard let s128 = report.nextStep() else { return XCTFail("128") } - report.noteReply(.init(resultCode: 1, record: echoRecord(s128.key, value: 0x32, lead: [0x01])), for: s128) - guard let s121 = report.nextStep() else { return XCTFail("121") } - report.noteReply(.init(resultCode: 3, record: [0x00]), for: s121) - - var flagSteps: [String] = [] - var candidateSteps: [String] = [] - while let step = report.nextStep() { - XCTAssertEqual(step.opcode, 128, "the refused verb must never be sent again") - switch step.group { - case .knownFlag: flagSteps.append(step.key) - case .candidate: candidateSteps.append(step.key) - case .discovery: XCTFail("discovery is over") - } - report.noteReply(.init(resultCode: 1, record: echoRecord(step.key, value: 0x31, lead: [0x01])), for: step) + batch: ConfigKeySweep.batch(from: 0)) + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) + var candidates = 0 + var steps = 0 + while let step = report.nextStep(), steps < DeviceConfigReadProbe.maxSteps { + steps += 1 + if step.group == .candidate { candidates += 1 } + report.noteReply(.init(resultCode: step.group == .candidate ? 0 : 1, + record: echoRecord(step.key, value: 0x32)), for: step) } - XCTAssertEqual(flagSteps, Array(flagKeys.dropFirst()), - "the discovery key is not read twice; every other flag is") - XCTAssertEqual(candidateSteps, DeviceConfigReadProbe.oxygenCandidateKeys, - "candidates fall back to the surviving verb") - XCTAssertEqual(report.steps, 2 + 15 + DeviceConfigReadProbe.oxygenCandidateKeys.count) - } - - func testCandidatesPreferTheDeviceConfigVerbWhenBothAnswer() { - var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: ["only_flag"], - candidateKeys: ["enable_spo2"]) - guard let a = report.nextStep() else { return XCTFail("128") } - report.noteReply(.init(resultCode: 1, record: echoRecord(a.key, value: 0x32)), for: a) - guard let b = report.nextStep() else { return XCTFail("121") } - report.noteReply(.init(resultCode: 1, record: echoRecord(b.key, value: 0x31)), for: b) - - // The only known flag was already read during discovery, so the next step is the candidate. - guard let c = report.nextStep() else { return XCTFail("candidate") } - XCTAssertEqual(c.group, .candidate) - XCTAssertEqual(c.opcode, 121, "the device-config namespace is the one this probe exists to reach") - XCTAssertEqual(c.key, "enable_spo2") - } - - func testThePlanIsCappedEvenWithAnAbsurdKeyList() { - let many = (0..<500).map { "key_\($0)" } - var report = DeviceConfigReadProbeReport(family: .whoop4, knownFlagKeys: many, candidateKeys: many) + XCTAssertEqual(candidates, ConfigKeySweep.catalogue.count) + XCTAssertNil(report.stopReason, "a full default run must not hit the safety cap") + XCTAssertTrue(report.render().contains("none untested")) + } + + /// The safety cap still binds, whatever the plan holds. + func testThePlanIsCappedEvenWhenTheStrapEnumeratesForever() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 9999))) var seen = 0 - while let step = report.nextStep() { + while let step = report.nextStep(), seen < 500 { seen += 1 - XCTAssertLessThanOrEqual(seen, DeviceConfigReadProbe.maxSteps + 1, "the plan must terminate") - report.noteReply(.init(resultCode: nil, record: echoRecord(step.key, value: 0x31)), for: step) + if step.group == .enumerate { + // A firmware whose cursor never advances: always a valid entry, never the end marker. + _ = report.noteEnumerationNext(nextReply(enumNext(index: 1, key: "whoop_stuck"))) + } else { + report.noteReply(.init(resultCode: 0, record: []), for: step) + } } - XCTAssertEqual(seen, DeviceConfigReadProbe.maxSteps) - XCTAssertEqual(report.stopReason, "safety cap of 64 round-trips reached") - } - - func testAnUndecodableReplyRetiresTheVerb() { - var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, candidateKeys: []) - guard let step = report.nextStep() else { return XCTFail("first") } - report.noteFailure(.crc, for: step) - XCTAssertEqual(report.featureFlagVerb, .undecodable) - guard let next = report.nextStep() else { return XCTFail("second") } - XCTAssertEqual(next.opcode, 121, "the undecodable verb is not retried") - report.noteTimeout(for: next, seconds: 8) - XCTAssertNil(report.nextStep()) - XCTAssertEqual(report.stopReason, "CRC failed — frame rejected (never decoded)") + XCTAssertLessThanOrEqual(report.steps, DeviceConfigReadProbe.maxSteps) + XCTAssertNotNil(report.stopReason) } - func testAVerdictAlreadyReachedIsNotRewrittenByALaterReply() { - var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: ["a", "b"], candidateKeys: []) - guard let first = report.nextStep() else { return XCTFail("first") } - report.noteReply(.init(resultCode: 1, record: echoRecord("a", value: 0x31)), for: first) - XCTAssertEqual(report.featureFlagVerb, .answered) - // A later UNSUPPORTED on the same verb sticks; a later SUCCESS after that does not undo it. - guard let second = report.nextStep() else { return XCTFail("second") } - report.noteReply(.init(resultCode: 3, record: [0x00]), for: second) - XCTAssertEqual(report.deviceConfigVerb, .unsupported) - report.noteReply(.init(resultCode: 1, record: [0x00]), for: second) - XCTAssertEqual(report.deviceConfigVerb, .unsupported, "a refusal is not upgraded away") + /// Drive the plan with enumeration refused, stopping at the FIRST candidate step and handing it back + /// alongside the report (a pulled step cannot be pushed back, so the helper must not swallow it). + private func driveToCandidates(limit: Int) + -> (report: DeviceConfigReadProbeReport, first: DeviceConfigReadProbeReport.Step?) { + var report = smallReport(limit: limit) + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) + while let step = report.nextStep() { + if step.group == .candidate { return (report, step) } + report.noteReply(.init(resultCode: 1, record: echoRecord(step.key, value: 0x32)), for: step) + } + return (report, nil) } // MARK: - Report - func testCandidateKeysAreLabelledAsGuesses() { - var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: ["f"], - candidateKeys: ["enable_spo2"]) - guard let a = report.nextStep() else { return XCTFail("a") } - report.noteReply(.init(resultCode: 1, record: echoRecord("f", value: 0x32)), for: a) - guard let b = report.nextStep() else { return XCTFail("b") } - report.noteReply(.init(resultCode: 1, record: echoRecord(b.key, value: 0x31)), for: b) - guard let c = report.nextStep() else { return XCTFail("c") } - report.noteReply(.init(resultCode: 0, record: [0x00]), for: c) - let text = report.render() - XCTAssertTrue(text.contains("Candidate oxygen keys — GUESSES, never observed on a wire or in any table")) - XCTAssertTrue(text.contains("enable_spo2")) - XCTAssertTrue(text.contains("no value (result=FAILURE(0))")) - } - - func testOxygenCandidateListIsShortAndOxygenNamed() { - let keys = DeviceConfigReadProbe.oxygenCandidateKeys - XCTAssertFalse(keys.isEmpty) - XCTAssertLessThanOrEqual(keys.count, 12, "keep the guess list short — it is a guess list") - XCTAssertEqual(Set(keys).count, keys.count, "no duplicates") - for k in keys { - XCTAssertTrue(k.contains("spo2") || k.contains("oxygen") || k.contains("pulse_ox"), - "\(k) does not read as oxygen-related") - XCTAssertLessThanOrEqual(k.utf8.count, DeviceConfigReadProbe.nameFieldBytes, - "\(k) would be truncated by the 32-byte name field") - } - } - - /// GOLDEN: the exact rendered report, byte-for-byte. Its Kotlin twin - /// (`DeviceConfigReadProbeTest.goldenReportIsByteIdenticalAcrossPlatforms`) asserts the SAME literal, - /// so a strap log reads identically on either platform — a whitespace or wording drift on one side - /// fails there rather than in a user's log. - /// - /// The first record's trailing `00`, and the seven-byte UNSUPPORTED record, are the puffin envelope's - /// 4-byte inner padding showing through — which is exactly why the value is read as "the byte after - /// the echoed name field" and not "the last byte of the record". + /// Byte-for-byte golden, asserted identically by the Kotlin twin, so a shared strap log reads the same + /// either side and a wording drift fails here rather than in a user's log. func testGoldenReportIsByteIdenticalAcrossPlatforms() { - var report = DeviceConfigReadProbeReport(family: .whoop5, - knownFlagKeys: ["enable_r22_packets", "hr_ch_switching"], - candidateKeys: ["enable_spo2"]) - // 128 answers with a value; 121 is refused; the remaining flag reads through 128; the guessed - // candidate falls back to 128 and comes back FAILURE. + var report = smallReport() guard let s1 = report.nextStep() else { return XCTFail("s1") } - let f1 = whoop5Response(cmd: 128, payload: payload(result: 1, - record: echoRecord("enable_r22_packets", value: 0x32, lead: [0x01]))) - guard case .success(let r1) = DeviceConfigReadProbe.parse(frame: f1, family: .whoop5, expecting: 128) else { - return XCTFail("parse s1") - } - report.noteReply(r1, for: s1) + XCTAssertEqual(s1.opcode, 115) + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) guard let s2 = report.nextStep() else { return XCTFail("s2") } - let f2 = whoop5Response(cmd: 121, payload: payload(result: 3, record: [0x00, 0x00, 0x00, 0x00])) - guard case .success(let r2) = DeviceConfigReadProbe.parse(frame: f2, family: .whoop5, expecting: 121) else { - return XCTFail("parse s2") - } - report.noteReply(r2, for: s2) - + report.noteReply(.init(resultCode: 1, record: echoRecord("enable_r22_packets", value: 0x32)), for: s2) guard let s3 = report.nextStep() else { return XCTFail("s3") } - report.noteReply(.init(resultCode: 1, record: echoRecord("hr_ch_switching", value: 0x32, lead: [0x01])), for: s3) - + report.noteReply(.init(resultCode: 1, + record: echoRecord(DeviceConfigReadProbe.deviceConfigDiscoveryKey, + value: 0x30)), for: s3) guard let s4 = report.nextStep() else { return XCTFail("s4") } report.noteReply(.init(resultCode: 0, record: [0x01, 0x00]), for: s4) + guard let s5 = report.nextStep() else { return XCTFail("s5") } + report.noteReply(.init(resultCode: 0, record: [0x01, 0x00]), for: s5) + guard let s6 = report.nextStep() else { return XCTFail("s6") } + report.noteReply(.init(resultCode: 1, record: echoRecord("hr_ch_switching", value: 0x32)), for: s6) + guard let c1 = report.nextStep() else { return XCTFail("c1") } + report.noteReply(.init(resultCode: 0, record: [0x01, 0x00]), for: c1) + guard let c2 = report.nextStep() else { return XCTFail("c2") } + report.noteReply(.init(resultCode: 0, record: [0x01, 0x00]), for: c2) XCTAssertNil(report.nextStep()) - let golden = """ - #103 DEVICE-CONFIG READ PROBE — WHOOP 5/MG - Read-only: GET_DEVICE_CONFIG_VALUE(121) + GET_FF_VALUE(128). No value is written; SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path. - Follow-up to the #761 enumeration probe: that one asked for key NAMES, this asks for VALUES. + XCTAssertEqual(report.render(), DeviceConfigReadProbeTests.goldenReport) + } - Verdict: 1 of 2 read verbs answered; read 2 config value(s) + static let goldenReport = """ +#103 CONFIG KEY PROBE — WHOOP 5/MG +Read-only: START_DEVICE_CONFIG_KEY_EXCHANGE(115), SEND_NEXT_DEVICE_CONFIG(116), GET_DEVICE_CONFIG_VALUE(121), GET_FF_VALUE(128). +No value is written; SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path. +Oracle: result=SUCCESS(1) means the key NAME exists; result=FAILURE(0) means the firmware has no such key. - Read verbs: - GET_FF_VALUE(128) answered - GET_DEVICE_CONFIG_VALUE(121) unsupported +Verdict: asked 2 candidate key name(s); this firmware has none of them (a clean negative) - Discovery — one round-trip per verb against a key it should know (2): - 1. enable_r22_packets = '2' (0x32) - 2. whoop_live_hr_in_adv_ind_pkt — no value (result=UNSUPPORTED(3)) +Verbs: + device-config enumerate(115/116) unsupported + GET_FF_VALUE(128) answered + GET_DEVICE_CONFIG_VALUE(121) answered - Known feature-flag values (names NOOP already writes; values never read before) (1): - 1. hr_ch_switching = '2' (0x32) +Device-config keys the strap listed for itself (115/116) (0): + (none — the firmware refused 115 as UNSUPPORTED) - Candidate oxygen keys — GUESSES, never observed on a wire or in any table (1): - 1. enable_spo2 — no value (result=FAILURE(0)) +Namespace separation: + 128 asked for a device-config key unknown + 121 asked for a feature-flag key unknown + ⇒ the namespaces are separate: neither verb sees the other's keys. - Exchange: - GET_FF_VALUE(128) key="enable_r22_packets" → result=SUCCESS(1) value='2' (0x32) record=[01 65 6e 61 62 6c 65 5f 72 32 32 5f 70 61 63 6b 65 74 73 00 00 00 00 00 00 00 00 00 00 00 00 00 00 32 00] - GET_DEVICE_CONFIG_VALUE(121) key="whoop_live_hr_in_adv_ind_pkt" → result=UNSUPPORTED(3) record=[00 00 00 00 00 00 00] - GET_FF_VALUE(128) key="hr_ch_switching" → result=SUCCESS(1) value='2' (0x32) record=[01 68 72 5f 63 68 5f 73 77 69 74 63 68 69 6e 67 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 32] - GET_FF_VALUE(128) key="enable_spo2" → result=FAILURE(0) record=[01 00] +Discovery — one round-trip per value verb against a key it should know (2): + 1. enable_r22_packets = '2' (0x32) + 2. whoop_live_hr_in_adv_ind_pkt = '0' (0x30) - """ - XCTAssertEqual(report.render(), golden) - } +Known key values (the flags NOOP writes, plus anything enumeration returned) (1): + 1. hr_ch_switching = '2' (0x32) + +Candidate key names — GUESSES, never observed on a wire or in any table (2 asked of 54 in the catalogue; 52 untested): + 0 exist · 2 do not · 0 inconclusive + + sig series (T8) — the firmware numbers its signal chains; sig11/sig12 are the two we have (2): + 1. enable_sig1 unknown + 2. enable_sig2 unknown + + Run the probe again to continue from catalogue entry 3. + +Exchange: + START_DEVICE_CONFIG_KEY_EXCHANGE(115) → result=UNSUPPORTED(3) — the firmware does not serve this verb + GET_FF_VALUE(128) key="enable_r22_packets" → result=SUCCESS(1) exists value='2' (0x32) record=[65 6e 61 62 6c 65 5f 72 32 32 5f 70 61 63 6b 65 74 73 00 00 00 00 00 00 00 00 00 00 00 00 00 00 32] + GET_DEVICE_CONFIG_VALUE(121) key="whoop_live_hr_in_adv_ind_pkt" → result=SUCCESS(1) exists value='0' (0x30) record=[77 68 6f 6f 70 5f 6c 69 76 65 5f 68 72 5f 69 6e 5f 61 64 76 5f 69 6e 64 5f 70 6b 74 00 00 00 00 30] + GET_FF_VALUE(128) key="whoop_live_hr_in_adv_ind_pkt" → result=FAILURE(0) unknown record=[01 00] + GET_DEVICE_CONFIG_VALUE(121) key="enable_r22_packets" → result=FAILURE(0) unknown record=[01 00] + GET_FF_VALUE(128) key="hr_ch_switching" → result=SUCCESS(1) exists value='2' (0x32) record=[68 72 5f 63 68 5f 73 77 69 74 63 68 69 6e 67 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 32] + +""" } diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index aaad54f415..e1a04675ab 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1520,13 +1520,16 @@ public final class BLEManager: NSObject, ObservableObject { // and the gate is the same state the command is about. Non-destructive: the strap frees // records on our HISTORY_END ack, not on this, so an aborted drain re-offloads intact. || (command == .abortHistoricalTransmits && backfilling) - // GET_DEVICE_CONFIG_VALUE (121) / GET_FF_VALUE (128) over puffin: the READ-ONLY - // device-config READ probe (#103) — it asks for a key's VALUE and writes none. Gated the - // same way as 117/118: allowed ONLY while a probe is actually in flight, and the opcode - // must additionally satisfy DeviceConfigReadProbe.isReadOnlyOpcode, the same predicate a - // unit test proves rejects SET_FF_VALUE(120) and SET_DEVICE_CONFIG_VALUE(119). Those two - // keep their own separate opt-in clauses below and are never sent from this path. Driven - // only by probeDeviceConfigValues() (user-initiated, Test Centre gated). + // START_DEVICE_CONFIG_KEY_EXCHANGE (115) / SEND_NEXT_DEVICE_CONFIG (116) / + // GET_DEVICE_CONFIG_VALUE (121) / GET_FF_VALUE (128) over puffin: the READ-ONLY config key + // probe (#103). 115/116 ask the strap to LIST its device-config keys (the device-config + // twin of the 117/118 pair above); 121/128 ask for a named key's VALUE. None of the four + // writes anything. Gated the same way as 117/118: allowed ONLY while a probe is actually + // in flight, and the opcode must additionally satisfy DeviceConfigReadProbe.isReadOnlyOpcode + // — the same predicate unit tests prove rejects SET_FF_VALUE(120) and + // SET_DEVICE_CONFIG_VALUE(119). Those two 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(command.rawValue) && deviceConfigReport != nil) || command == .sendHistoricalData || command == .historicalDataResult || command == .setClock || command == .getClock @@ -2709,6 +2712,11 @@ public final class BLEManager: NSObject, ObservableObject { private var deviceConfigAwaiting: DeviceConfigReadProbeReport.Step? /// Monotonic step counter so a late timeout from an earlier step can't cancel a live walk. private var deviceConfigStep = 0 + /// Where the next run's candidate sweep resumes in `ConfigKeySweep.catalogue`. Deliberately IN MEMORY + /// — no new storage, no migration, and a relaunch restarting at the top of the catalogue is the right + /// default. With today's catalogue smaller than `maxKeysPerRun` this stays 0 and every run tests all + /// of it; it exists so a catalogue grown past the budget resumes instead of re-asking the same slice. + private var configKeySweepCursor = 0 /// #103 read-only probe: ask the strap for config VALUES — `GET_DEVICE_CONFIG_VALUE(121)` and /// `GET_FF_VALUE(128)`, one key per round-trip. The #761 probe asked the strap for key NAMES in the @@ -2719,12 +2727,12 @@ public final class BLEManager: NSObject, ObservableObject { /// kind — this writes command frames purely to read, exactly like the Oura `spo2_status` / /// `realsteps_status` probes NOOP already ships (`Packages/OuraProtocol/…/Commands.swift`). /// - /// **Both target opcodes may simply be unimplemented.** The probe spends one round-trip per verb - /// establishing that before it does anything else, and a clean "neither verb is served" is a useful - /// result. Only a verb that answers goes on to read the sixteen known flag values and the short list - /// of guessed oxygen key names. Result goes to `LiveState.deviceConfigProbe` (the Devices dialog) and - /// to the strap log — no new storage. User-initiated only, Test Centre → Connection gated. Twin of - /// Android `probeDeviceConfigValues()`. + /// The plan asks the strap before it guesses: `START_DEVICE_CONFIG_KEY_EXCHANGE(115)` + + /// `SEND_NEXT_DEVICE_CONFIG(116)` first, and if they answer the strap has listed its own device-config + /// keys and the guessed-name sweep is skipped entirely. A clean "115/116 are not served" is equally + /// useful — it is what makes guessing the only available method. Result goes to + /// `LiveState.deviceConfigProbe` (the Devices dialog) and to the strap log — no new storage. + /// User-initiated only, Test Centre → Connection gated. Twin of Android `probeDeviceConfigValues()`. public func probeDeviceConfigValues() { guard state.connected else { log("Device-config read probe (#103) ignored — not connected") @@ -2744,9 +2752,9 @@ public final class BLEManager: NSObject, ObservableObject { family: selectedModel.deviceFamily, // The flag names come from NOOP's own R22 sequence — never restated here. knownFlagKeys: Whoop5Config.enableR22Sequence.map(\.name), - candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) + batch: ConfigKeySweep.batch(from: configKeySweepCursor)) state.deviceConfigProbe = BLEManager.deviceConfigProbeWaiting - log("Device-config read probe (#103): asking for config VALUES via GET_DEVICE_CONFIG_VALUE(121) + GET_FF_VALUE(128) on family=\(selectedModel.deviceFamily); read-only (SET_FF_VALUE/120 and SET_DEVICE_CONFIG_VALUE/119 are never sent from this path)") + log("Config key probe (#103): enumerating device-config keys via START_DEVICE_CONFIG_KEY_EXCHANGE(115)/SEND_NEXT_DEVICE_CONFIG(116), then reading VALUES via GET_DEVICE_CONFIG_VALUE(121)/GET_FF_VALUE(128) on family=\(selectedModel.deviceFamily); read-only (SET_FF_VALUE/120 and SET_DEVICE_CONFIG_VALUE/119 are never sent from this path)") advanceDeviceConfigProbe() } @@ -2767,7 +2775,12 @@ public final class BLEManager: NSObject, ObservableObject { deviceConfigStep &+= 1 deviceConfigAwaiting = step let armed = deviceConfigStep - send(command, payload: DeviceConfigReadProbe.requestBody(key: step.key)) + // The enumeration verbs carry the bare b3 byte (the strap walks its own cursor); the value verbs + // carry the b3 byte plus the 32-byte key-name field. + let payload = step.group == .enumerate + ? ConfigKeySweep.enumerationRequestBody + : DeviceConfigReadProbe.requestBody(key: step.key) + send(command, payload: payload) // BLE callbacks + this timer both run on the main queue, so the guard-then-advance is race-free: // a reply that already landed advanced `deviceConfigStep`, and this stale closure no-ops. DispatchQueue.main.asyncAfter(deadline: .now() + BLEManager.deviceConfigProbeTimeout) { [weak self] in @@ -2787,29 +2800,58 @@ public final class BLEManager: NSObject, ObservableObject { guard let report = deviceConfigReport else { return } deviceConfigReport = nil deviceConfigAwaiting = nil + // Advance the sweep so a catalogue larger than one run's budget continues where this run stopped + // rather than re-asking the same slice. Wraps to 0 at the end of the catalogue. + configKeySweepCursor = report.batch.nextCursor let text = report.render() - log("Device-config read probe (#103):\n\(text)") + log("Config key probe (#103):\n\(text)") state.deviceConfigProbe = text } /// Clear the #103 probe result (Devices dialog dismissed). Twin of Android clearDeviceConfigProbe(). public func clearDeviceConfigProbe() { state.deviceConfigProbe = nil } - /// #103: one COMMAND_RESPONSE for 121/128. Guarded on a probe being IN-FLIGHT (like #690/#761) so a - /// stray byte match can never surface a result. Parsing — including the CRC gate — lives in the pure - /// `DeviceConfigReadProbe`; a frame that fails any check retires that verb with a named reason - /// instead of being decoded. + /// #103: one COMMAND_RESPONSE for 115/116/121/128. Guarded on a probe being IN-FLIGHT (like #690/#761) + /// so a stray byte match can never surface a result. Parsing — including the CRC gate — lives in the + /// pure `DeviceConfigReadProbe` / `FeatureFlagProbe`; a frame that fails any check retires that verb + /// with a named reason instead of being decoded. private func handleDeviceConfigProbeResponse(_ frame: [UInt8], isWhoop5: Bool) { guard deviceConfigReport != nil, let step = deviceConfigAwaiting else { return } deviceConfigAwaiting = nil let family: DeviceFamily = isWhoop5 ? .whoop5 : .whoop4 - switch DeviceConfigReadProbe.parse(frame: frame, family: family, expecting: step.opcode) { - case .success(let r): deviceConfigReport?.noteReply(r, for: step) - case .failure(let f): deviceConfigReport?.noteFailure(f, for: step) + // The enumeration replies share the 117/118 record layout, so they are decoded by that parser with + // the device-config opcode passed in; the value replies keep their own decoder. + switch step.opcode { + case ConfigKeySweep.startDeviceConfigKeyExchangeCmd: + switch FeatureFlagProbe.parseStart(frame: frame, family: family, expecting: step.opcode) { + case .success(let r): deviceConfigReport?.noteEnumerationStart(r) + case .failure(let f): deviceConfigReport?.noteFailure(configFailure(f), for: step) + } + case ConfigKeySweep.sendNextDeviceConfigCmd: + switch FeatureFlagProbe.parseNext(frame: frame, family: family, expecting: step.opcode) { + case .success(let r): deviceConfigReport?.noteEnumerationNext(r) + case .failure(let f): deviceConfigReport?.noteFailure(configFailure(f), for: step) + } + default: + switch DeviceConfigReadProbe.parse(frame: frame, family: family, expecting: step.opcode) { + case .success(let r): deviceConfigReport?.noteReply(r, for: step) + case .failure(let f): deviceConfigReport?.noteFailure(f, for: step) + } } advanceDeviceConfigProbe() } + /// The two probes name the same four decode failures in separate enums; map one onto the other so the + /// enumeration half reports through the same `DeviceConfigReadProbeReport.noteFailure` path. + private func configFailure(_ f: FeatureFlagProbe.ParseFailure) -> DeviceConfigReadProbe.ParseFailure { + switch f { + case .crc: return .crc + case .envelope: return .envelope + case .wrongCommand: return .wrongCommand + case .truncated: return .truncated + } + } + /// #761: one COMMAND_RESPONSE for 117/118. Guarded on a probe being IN-FLIGHT (like #690) so a stray /// byte match can never surface a result. Parsing — including the CRC gate — lives in the pure /// `FeatureFlagProbe`; a frame that fails any check ends the walk with a named reason instead of diff --git a/Strand/BLE/Commands.swift b/Strand/BLE/Commands.swift index 406aba2c6b..b4622f82e4 100644 --- a/Strand/BLE/Commands.swift +++ b/Strand/BLE/Commands.swift @@ -95,6 +95,21 @@ public enum WhoopCommand: UInt8, CaseIterable { /// #690: read-only body-location/status probe. Documented in the WHOOP protocol; driven only by the /// user-triggered, Test-Centre-gated probeBodyLocationAndStatus(). Decoded to a diagnostic report only. case getBodyLocationAndStatus = 84 + /// START_DEVICE_CONFIG_KEY_EXCHANGE (115 / 0x73) — ask the strap how many DEVICE-CONFIG keys its + /// firmware knows. READ-ONLY: the reply carries a count, and nothing on the strap changes. Payload + /// `[0x01]`. This is the device-config twin of 117, and the half of the config surface nothing here + /// has ever sent: the `CommandNumber` table names 115/116 alongside 119/121, and only 119 (write) and + /// 121 (read one value) were implemented. If it answers, the strap lists its own device-config keys + /// and #103 stops needing to guess names. Driven ONLY by `BLEManager.probeDeviceConfigValues()` — + /// user-initiated, Test Centre → Connection gated. Parsing reuses `FeatureFlagProbe.parseStart` on the + /// assumed-symmetric layout. (#103) + case startDeviceConfigKeyExchange = 115 + /// SEND_NEXT_DEVICE_CONFIG (116 / 0x74) — advance the strap's own device-config cursor and report one + /// key NAME. READ-ONLY: names only, no values, nothing written. Payload `[0x01]`; a CURSOR, not an + /// index, so the same frame is repeated to walk the list. Bounded by + /// `ConfigKeySweep.maxEnumerationSteps` and by the strap's own end marker. Driven ONLY by + /// `BLEManager.probeDeviceConfigValues()`. (#103) + case sendNextDeviceConfig = 116 /// START_FF_KEY_EXCHANGE (117 / 0x75) — ask the strap how many feature flags its firmware knows. /// READ-ONLY: the reply carries a count, and nothing on the strap changes. Payload `[0x01]` (the /// inner b3 byte the SET_CONFIG family and GET_HELLO use). This is the READ half of the flag surface @@ -189,6 +204,8 @@ public enum WhoopCommand: UInt8, CaseIterable { case .exitHighFreqSync: return "Exit High-Freq Sync" case .getExtendedBatteryInfo:return "Get Extended Battery Info" case .getBodyLocationAndStatus:return "Get Body Location And Status" + case .startDeviceConfigKeyExchange: return "Start Device-Config Key Exchange" + case .sendNextDeviceConfig: return "Send Next Device Config" case .startFeatureFlagKeyExchange: return "Start Feature-Flag Key Exchange" case .sendNextFeatureFlag: return "Send Next Feature Flag" case .getDeviceConfigValue: return "Get Device Config Value" 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 53d3c0dfd5..5df8aa0ee3 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,7 @@ import com.noop.protocol.BackfillCaptureJsonl import com.noop.protocol.BackfillCaptureRecord import com.noop.protocol.BackfillCaptureSummary import com.noop.protocol.CommandNumber +import com.noop.protocol.ConfigKeySweep import com.noop.protocol.DeviceFamily import com.noop.protocol.DeviceConfigReadProbe import com.noop.protocol.DeviceConfigReadProbeReport @@ -1548,6 +1549,13 @@ class WhoopBleClient( /** Monotonic step counter so a late timeout from an earlier step can't cancel a live walk. */ private var deviceConfigStep = 0 + /** Where the next run's candidate sweep resumes in [ConfigKeySweep.CATALOGUE]. Deliberately IN MEMORY + * — no new storage, no migration, and a relaunch restarting at the top of the catalogue is the right + * default. With today's catalogue smaller than [ConfigKeySweep.MAX_KEYS_PER_RUN] this stays 0 and + * every run tests all of it; it exists so a catalogue grown past the budget resumes instead of + * re-asking the same slice. Twin of macOS BLEManager.configKeySweepCursor. */ + private var configKeySweepCursor = 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. */ @@ -2851,13 +2859,16 @@ class WhoopBleClient( // probeFeatureFlags() (user-initiated, Test Centre gated). !((cmd == CommandNumber.START_FF_KEY_EXCHANGE || cmd == CommandNumber.SEND_NEXT_FF) && featureFlagReport != null) && - // GET_DEVICE_CONFIG_VALUE (121) / GET_FF_VALUE (128) over puffin: the READ-ONLY - // device-config READ probe (#103) — it asks for a key's VALUE and writes none. Gated the - // same way as 117/118: allowed ONLY while a probe is actually in flight, and the opcode - // must additionally satisfy DeviceConfigReadProbe.isReadOnlyOpcode, the same predicate a - // unit test proves rejects SET_FF_VALUE(120) and SET_DEVICE_CONFIG_VALUE(119). Those two - // keep their own separate opt-in clauses below and are never sent from this path. Driven - // only by probeDeviceConfigValues() (user-initiated, Test Centre gated). + // START_DEVICE_CONFIG_KEY_EXCHANGE (115) / SEND_NEXT_DEVICE_CONFIG (116) / + // GET_DEVICE_CONFIG_VALUE (121) / GET_FF_VALUE (128) over puffin: the READ-ONLY config key + // probe (#103). 115/116 ask the strap to LIST its device-config keys (the device-config + // twin of the 117/118 pair above); 121/128 ask for a named key's VALUE. None of the four + // writes anything. Gated the same way as 117/118: allowed ONLY while a probe is actually in + // flight, and the opcode must additionally satisfy DeviceConfigReadProbe.isReadOnlyOpcode — + // the same predicate unit tests prove rejects SET_FF_VALUE(120) and + // SET_DEVICE_CONFIG_VALUE(119). Those two 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 @@ -3397,13 +3408,14 @@ class WhoopBleClient( connectedFamily, // The flag names come from NOOP's own R22 sequence — never restated here. Whoop5Config.enableR22Sequence.map { it.name }, - DeviceConfigReadProbe.OXYGEN_CANDIDATE_KEYS, + ConfigKeySweep.batch(configKeySweepCursor), ) _deviceConfigProbe.value = WAITING_DEVICE_CONFIG_PROBE log( - "Device-config read probe (#103): asking for config VALUES via GET_DEVICE_CONFIG_VALUE(121) + " + - "GET_FF_VALUE(128) on family=$connectedFamily; read-only (SET_FF_VALUE/120 and " + - "SET_DEVICE_CONFIG_VALUE/119 are never sent from this path)", + "Config key probe (#103): enumerating device-config keys via " + + "START_DEVICE_CONFIG_KEY_EXCHANGE(115)/SEND_NEXT_DEVICE_CONFIG(116), then reading VALUES " + + "via GET_DEVICE_CONFIG_VALUE(121)/GET_FF_VALUE(128) on family=$connectedFamily; read-only " + + "(SET_FF_VALUE/120 and SET_DEVICE_CONFIG_VALUE/119 are never sent from this path)", ) advanceDeviceConfigProbe() } @@ -3426,7 +3438,14 @@ class WhoopBleClient( deviceConfigStep += 1 deviceConfigAwaiting = step val armed = deviceConfigStep - send(cmd, DeviceConfigReadProbe.requestBody(step.key)) + // The enumeration verbs carry the bare b3 byte (the strap walks its own cursor); the value verbs + // carry the b3 byte plus the 32-byte key-name field. + val payload = if (step.group == DeviceConfigReadProbeReport.Group.ENUMERATE) { + ConfigKeySweep.ENUMERATION_REQUEST_BODY + } else { + DeviceConfigReadProbe.requestBody(step.key) + } + send(cmd, payload) // A reply that already landed advanced deviceConfigStep, so this stale closure no-ops. handler.postDelayed({ if (deviceConfigReport != null && deviceConfigStep == armed && deviceConfigAwaiting != null) { @@ -3444,8 +3463,11 @@ class WhoopBleClient( val report = deviceConfigReport ?: return deviceConfigReport = null deviceConfigAwaiting = null + // Advance the sweep so a catalogue larger than one run's budget continues where this run stopped + // rather than re-asking the same slice. Wraps to 0 at the end of the catalogue. + configKeySweepCursor = report.batch.nextCursor val text = report.render() - log("Device-config read probe (#103):\n$text") + log("Config key probe (#103):\n$text") _deviceConfigProbe.value = text } @@ -3462,16 +3484,50 @@ class WhoopBleClient( if (deviceConfigReport == null) return val step = deviceConfigAwaiting ?: return deviceConfigAwaiting = null - val parsed = DeviceConfigReadProbe.parse(frame, connectedFamily, step.opcode) - val value = parsed.value - if (value != null) { - deviceConfigReport?.noteReply(value, step) - } else { - deviceConfigReport?.noteFailure(parsed.failure!!, step) + // The enumeration replies share the 117/118 record layout, so they are decoded by that parser with + // the device-config opcode passed in; the value replies keep their own decoder. + when (step.opcode) { + ConfigKeySweep.START_DEVICE_CONFIG_KEY_EXCHANGE_CMD -> { + val parsed = FeatureFlagProbe.parseStart(frame, connectedFamily, step.opcode) + val value = parsed.value + if (value != null) { + deviceConfigReport?.noteEnumerationStart(value) + } else { + deviceConfigReport?.noteFailure(configFailure(parsed.failure!!), step) + } + } + ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD -> { + val parsed = FeatureFlagProbe.parseNext(frame, connectedFamily, step.opcode) + val value = parsed.value + if (value != null) { + deviceConfigReport?.noteEnumerationNext(value) + } else { + deviceConfigReport?.noteFailure(configFailure(parsed.failure!!), step) + } + } + else -> { + val parsed = DeviceConfigReadProbe.parse(frame, connectedFamily, step.opcode) + val value = parsed.value + if (value != null) { + deviceConfigReport?.noteReply(value, step) + } else { + deviceConfigReport?.noteFailure(parsed.failure!!, step) + } + } } advanceDeviceConfigProbe() } + /** The two probes name the same four decode failures in separate enums; map one onto the other so the + * enumeration half reports through the same [DeviceConfigReadProbeReport.noteFailure] path. */ + private fun configFailure(f: FeatureFlagProbe.ParseFailure): DeviceConfigReadProbe.ParseFailure = + when (f) { + FeatureFlagProbe.ParseFailure.CRC -> DeviceConfigReadProbe.ParseFailure.CRC + FeatureFlagProbe.ParseFailure.ENVELOPE -> DeviceConfigReadProbe.ParseFailure.ENVELOPE + FeatureFlagProbe.ParseFailure.WRONG_COMMAND -> DeviceConfigReadProbe.ParseFailure.WRONG_COMMAND + FeatureFlagProbe.ParseFailure.TRUNCATED -> DeviceConfigReadProbe.ParseFailure.TRUNCATED + } + /** * #761: one COMMAND_RESPONSE for 117/118. Guarded on a probe being IN-FLIGHT (like #690) so a stray * byte match can never surface a result. Parsing — including the CRC gate — lives in the pure diff --git a/android/app/src/main/java/com/noop/protocol/ConfigKeySweep.kt b/android/app/src/main/java/com/noop/protocol/ConfigKeySweep.kt new file mode 100644 index 0000000000..12b4e40bd1 --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/ConfigKeySweep.kt @@ -0,0 +1,371 @@ +package com.noop.protocol + +/** + * #103: the DEVICE-CONFIG ENUMERATION verbs (115/116), the key-existence ORACLE that #890's read verbs + * turned out to be, and the candidate-name catalogue the sweep falls back to when enumeration is refused. + * + * Kotlin twin of Swift `ConfigKeySweep` (`Packages/WhoopProtocol/…/ConfigKeySweep.swift`). The catalogue, + * the batching arithmetic and the oracle mapping are byte-identical across platforms; unit tests on both + * sides assert it. + * + * ## 1. Enumerate. Only guess what enumeration cannot reach. + * + * This repo's own protocol table (`whoop_protocol.json`, [CommandNumber]) names two symmetric config + * namespaces, four verbs each: + * + * ``` + * feature-flag 117 START_FF_KEY_EXCHANGE 118 SEND_NEXT_FF + * 120 SET_FF_VALUE 128 GET_FF_VALUE + * device-config 115 START_DEVICE_CONFIG_KEY_EXCHANGE 116 SEND_NEXT_DEVICE_CONFIG + * 119 SET_DEVICE_CONFIG_VALUE 121 GET_DEVICE_CONFIG_VALUE + * ``` + * + * #872 built the feature-flag enumerate pair (117/118) and a WHOOP 5 MG answered it, listing the sixteen + * keys in [Whoop5Config.enableR22Sequence]. #890 built both VALUE reads (121/128) and the same strap + * answered those too. **The device-config enumerate pair — 115/116 — has never been sent by anything + * here**, and it is the structural twin of the pair that already works. + * + * That matters more than any amount of name-guessing: if 115/116 answer, the strap simply hands over its + * own device-config key list. No dictionary, no morphology, no oracle sweep. So this probe asks first and + * guesses second, and the candidate catalogue below exists only for the case where it is refused. + * + * The 115/116 record layouts are ASSUMED to match their 117/118 twins ([FeatureFlagProbe.parseStart] / + * [FeatureFlagProbe.parseNext], parameterised by opcode). That is an inference from the naming symmetry in + * this repo's own table, not an observation, and it fails closed: a layout mismatch surfaces as a short + * record or an implausible count and retires the walk with a named reason rather than inventing key names. + * + * ## 2. The oracle + * + * On a WHOOP 5 MG (WS50_r03) the 121/128 reads answer differently for a key name the firmware knows and + * one it does not: `SUCCESS(1)` means the key EXISTS in that namespace, `FAILURE(0)` means the firmware + * has no key by that name. One round-trip is therefore a key-existence test — read-only, cheap, and + * decisive. [Existence] is that mapping and it is the ONLY thing the sweep concludes from a reply: + * `UNSUPPORTED(3)`, anything else, and the unlabelled result byte on WHOOP 4.0 all stay + * [Existence.INCONCLUSIVE] rather than being folded into either answer. + * + * ## 3. How the fallback candidates were DERIVED + * + * Not by free association. Every name is a cross-product of two things already in this repo. + * + * **A — morphology**, the templates the seventeen CONFIRMED key names follow (the sixteen in + * [Whoop5Config.enableR22Sequence], plus `whoop_live_hr_in_adv_ind_pkt`, the Broadcast-HR key NOOP has + * written since #181 and which #890 uses as its known-good device-config control): + * + * ``` + * T1 enable__packets enable_r22_packets + * T2 enable__v_packets enable_r22_v2_packets … enable_r22_v8_packets + * T3 disable___packets disable_pip_r26_packets + * T4 make__visible make_hrfm_visible + * T5 __switching hr_ch_switching, ir_hw_switching + * T6 _detect_bias wear_detect_bias + * T7 enable__gen5 enable_passive_strap_fit_gen5 + * T8 enable_sig[_during_sleep] enable_sig11_during_sleep, enable_sig12 + * T9 _inhibit_ dorset_inhibit_wpt + * T10 whoop__in_ whoop_live_hr_in_adv_ind_pkt (device-config namespace) + * ``` + * + * **B — vocabulary**, the subsystem and revision tokens the firmware uses about itself. Two in-repo + * sources, no others: + * + * - the [CommandNumber] table: `optical` (107 `ENABLE_OPTICAL_DATA`, 108 `TOGGLE_OPTICAL_MODE`), + * `labrador` (124 `TOGGLE_LABRADOR_DATA_GENERATION`, 125 `TOGGLE_LABRADOR_RAW_SAVE`, 139 + * `TOGGLE_LABRADOR_FILTERED`), `research` (131/132), `afe` (61/62), `led`+`drive` (39/40), `raw` + * (81/82), and the revision tokens `r7` (16 `TOGGLE_R7_DATA_COLLECTION`), `r10`/`r11` (63 + * `SEND_R10_R11_REALTIME`), `r20`/`r21` (153/154 `TOGGLE_PERSISTENT_R20`/`_R21`); + * - the strap's own plaintext console log, whose subsystem tags this package already documents: + * `SENSORS: AFE configuration changed`, and — directly on point — + * **`SIGPROC: generated a valid SPO2 during sleep`**. That single line pairs the firmware's SpO2 + * computation with the `SIGPROC` tag and with the exact `during sleep` phrasing the confirmed key + * `enable_sig11_during_sleep` uses, which is the strongest in-repo reason to think the `sig` series + * is where an oxygen gate would live. + * + * Every candidate is (template × token). None is a product name invented in English — which matters, + * because the eight plain-English oxygen names in [RETIRED_KEYS] were all asked of a real WHOOP 5 MG and + * all came back FAILURE. + * + * **They are still guesses.** A candidate is a question, not a claim; the answer is [Existence], and a + * fully-negative sweep rules out a whole family of names, which is a publishable result. + */ +object ConfigKeySweep { + + /** + * `START_DEVICE_CONFIG_KEY_EXCHANGE` (115 / 0x73) — ask the strap how many device-config keys it + * knows. Read-only, and the structural twin of `START_FF_KEY_EXCHANGE` (117), which #872 shipped and a + * real strap answered. Named in this repo's `CommandNumber` table; never before sent by NOOP. + */ + const val START_DEVICE_CONFIG_KEY_EXCHANGE_CMD = 115 + + /** + * `SEND_NEXT_DEVICE_CONFIG` (116 / 0x74) — advance the strap's own cursor and report one key name. + * Read-only; the twin of `SEND_NEXT_FF` (118). Like 118 it carries a CURSOR, not an index: the same + * body is sent repeatedly and the strap walks its own list. + */ + const val SEND_NEXT_DEVICE_CONFIG_CMD = 116 + + /** Request body for both enumeration commands: the inner b3 byte `0x01`. */ + val ENUMERATION_REQUEST_BODY: ByteArray get() = byteArrayOf(0x01) + + /** + * Hard ceiling on 116 round-trips in one probe, independent of the count the strap announces. A + * firmware that answers with a nonsense count — or never advances its own cursor — must not be able to + * drive an unbounded write loop on the command characteristic. + */ + const val MAX_ENUMERATION_STEPS = 40 + + /** + * Ceiling on how many enumerated device-config key names the probe then reads VALUES for, so a long + * key list cannot spend the whole step budget. + */ + const val MAX_ENUMERATED_VALUE_READS = 40 + + /** + * What one 121/128 reply says about whether the key NAME exists. The result code, not the value, is + * the signal — that is what makes a name sweep possible at all. + * + * Confirmed on a WHOOP 5 MG (WS50_r03): a key the firmware knows answers `SUCCESS(1)` and carries a + * value byte; a name it does not know answers `FAILURE(0)`. Every other code — including + * `UNSUPPORTED(3)`, and the result byte on WHOOP 4.0 where this codebase has never pinned its + * meaning — is [INCONCLUSIVE] rather than being coerced into an answer. + */ + enum class Existence(val label: String) { + /** `result = SUCCESS(1)`. The firmware has this key. */ + EXISTS("exists"), + + /** `result = FAILURE(0)`. The firmware has no key by this name. */ + UNKNOWN("unknown"), + + /** Any other result code, or none at all. Says nothing either way. */ + INCONCLUSIVE("inconclusive"), + } + + /** + * Map a 5/MG result code onto the oracle. `null` — WHOOP 4.0, where this codebase has not established + * the byte's meaning — is [Existence.INCONCLUSIVE], never [Existence.UNKNOWN]. + */ + fun existence(resultCode: Int?): Existence = when (resultCode) { + 1 -> Existence.EXISTS + 0 -> Existence.UNKNOWN + else -> Existence.INCONCLUSIVE + } + + /** + * Which namespace a candidate is asked through. The two are separate: 117/118 enumerated the sixteen + * R22 flags and nothing else, while the Broadcast-HR key `whoop_live_hr_in_adv_ind_pkt` (#181) is a + * device-config key and is not among them. The probe's cross-namespace step can override this at run + * time if one verb turns out to serve both. + */ + enum class Namespace { FEATURE_FLAG, DEVICE_CONFIG } + + /** + * Which derivation produced a candidate. Groups the report, and — more usefully — lets a negative + * sweep rule out a whole FAMILY of names rather than just a list of strings. [title] states the + * derivation so a strap log pasted into an issue explains where the names came from. + */ + enum class Derivation(val title: String) { + SIG_SERIES( + "sig series (T8) — the firmware numbers its signal chains; sig11/sig12 are the two we have", + ), + R22_VERSION_GAPS( + "r22 version gaps (T2) — NOOP writes v2…v6 and v8; v7 and v1 are absent from an otherwise contiguous run", + ), + REVISION_SLOT( + "revision slot (T1/T3) — r7/r10/r11/r20/r21 are named in this repo's own CommandNumber table", + ), + OPTICAL_AFE( + "optical + AFE (T1/T4/T5) — 107 ENABLE_OPTICAL_DATA, 108 TOGGLE_OPTICAL_MODE, 61/62 AFE_PARAMETERS", + ), + LABRADOR_ECG( + "labrador / ECG (T1/T4) — 124/125/139 name LABRADOR in this repo's CommandNumber table", + ), + RESEARCH_HIGH_RATE( + "research + high-rate (T1/T4) — 131/132 RESEARCH_PACKET, 81/82 RAW_DATA, and hrfm from make_hrfm_visible", + ), + SIGPROC_OXYGEN( + "SIGPROC + oxygen (T1/T4/T5/T7) — the strap's console log says \"SIGPROC: generated a valid SPO2 during sleep\"", + ), + DEVICE_CONFIG_NAMESPACE( + "device-config namespace (T10) — the whoop__in_ shape of the one key we know", + ), + } + + /** + * One candidate key name: a QUESTION for the oracle, with the derivation that produced it and the + * namespace it is asked through. None has been observed on a wire, in a capture, or in any table. + */ + data class Candidate( + val key: String, + val derivation: Derivation, + val namespace: Namespace = Namespace.FEATURE_FLAG, + ) + + /** Build one derivation's candidates without repeating it on every line. */ + private fun names( + derivation: Derivation, + namespace: Namespace, + keys: List, + ): List = keys.map { Candidate(it, derivation, namespace) } + + /** + * **The candidate catalogue — the one place to add a name.** Every entry is (template × token); both + * lists are in this file's doc comment. Order is the sweep order, strongest derivation first. Keep in + * lockstep with the Swift `ConfigKeySweep.catalogue`. + */ + val CATALOGUE: List = + // T8. sig11 and sig12 are the only members of this series anyone here has seen, and the strap's own + // console tag SIGPROC — the tag on the line "generated a valid SPO2 during sleep" — is the likeliest + // expansion of "sig". A contiguous walk of the number line needs no guessing at all: it asks which + // N exist. Crossing the survivors with qualifiers is a cheap second pass once the line is known. + names( + Derivation.SIG_SERIES, + Namespace.FEATURE_FLAG, + listOf( + "enable_sig1", "enable_sig2", "enable_sig3", "enable_sig4", "enable_sig5", + "enable_sig6", "enable_sig7", "enable_sig8", "enable_sig9", "enable_sig10", + "enable_sig13", "enable_sig14", "enable_sig15", "enable_sig16", + // The two qualifier swaps on the pair we do have. + "enable_sig11", "enable_sig12_during_sleep", + ), + ) + + // T2. The series NOOP writes is v2, v3, v4, v5, v6, v8 — v7 is MISSING from an otherwise + // contiguous run, and there is no v1. Interpolating a hole in an OBSERVED series is the cheapest + // possible test that the oracle finds keys NOOP does not already know: if v7 answers SUCCESS, + // the method is proven on the first run and every other family becomes worth extending. + names( + Derivation.R22_VERSION_GAPS, + Namespace.FEATURE_FLAG, + listOf( + "enable_r22_v1_packets", "enable_r22_v7_packets", + "enable_r22_v9_packets", "enable_r22_v10_packets", + ), + ) + + // T1/T3. The revision slot. r22 and r26 appear in the confirmed keys; r7, r10, r11, r20 and r21 + // appear as revision tokens in this repo's own CommandNumber table. r16 and r17 fill the gap + // between the two attested clusters, and are the pair worth settling either way. + names( + Derivation.REVISION_SLOT, + Namespace.FEATURE_FLAG, + listOf( + "enable_r7_packets", "enable_r10_packets", "enable_r11_packets", + "enable_r16_packets", "enable_r17_packets", + "enable_r20_packets", "enable_r21_packets", + // The polarity swap on disable_pip_r26_packets, the one T3 instance there is. + "enable_pip_r26_packets", + ), + ) + + // T1/T4/T5. SpO2 is an optical measurement, so if a config key gates it, the firmware's optical + // and analog-front-end vocabulary is where it would be spelled. Note 107 ENABLE_OPTICAL_DATA is + // literally the T1 template already, which is why enable_optical_data leads. + names( + Derivation.OPTICAL_AFE, + Namespace.FEATURE_FLAG, + listOf( + "enable_optical_data", "enable_optical_packets", "make_optical_visible", + "enable_afe_packets", "red_hw_switching", "green_hw_switching", + ), + ) + + // T1/T4. LABRADOR is the firmware's own codename for a data path this repo's CommandNumber + // table gives three verbs (124 data generation, 125 raw save, 139 filtered) and which NOOP has + // never enabled. Its DATA_GENERATION / RAW_SAVE / FILTERED triad mirrors the ECG family's shape. + names( + Derivation.LABRADOR_ECG, + Namespace.FEATURE_FLAG, + listOf( + "enable_labrador_packets", "enable_labrador_raw_save", "enable_labrador_filtered", + "make_labrador_visible", "enable_ecg_packets", + ), + ) + + // T1/T4. The research and high-rate paths: 131/132 SET/GET_RESEARCH_PACKET, 81/82 + // START/STOP_RAW_DATA, and `hrfm`, which the confirmed key make_hrfm_visible already names. + names( + Derivation.RESEARCH_HIGH_RATE, + Namespace.FEATURE_FLAG, + listOf( + "enable_research_packets", "make_research_visible", + "enable_raw_packets", "enable_hrfm_packets", + ), + ) + + // T1/T4/T5/T7, plus the console tag. The eight plain-English oxygen names in RETIRED_KEYS all + // returned FAILURE, so these deliberately do not repeat that approach: each is a CONFIRMED + // template with `spo2` dropped into the token slot, and the last two use the strap's own + // `SIGPROC` tag and its own "during sleep" phrasing. + names( + Derivation.SIGPROC_OXYGEN, + Namespace.FEATURE_FLAG, + listOf( + "make_spo2_visible", "enable_spo2_during_sleep", "enable_spo2_gen5", + "spo2_ch_switching", "disable_spo2_packets", + "enable_sigproc_spo2", "sigproc_spo2_during_sleep", + ), + ) + + // T10, and the only family asked through 121 by default. The one confirmed device-config key is + // `whoop_live_hr_in_adv_ind_pkt`: `whoop_` + a live metric + the transport it rides. Swapping + // the metric is the most direct template swap available in that namespace — and it is the + // namespace 115/116 would have enumerated outright, so these only get asked when enumeration + // is refused. + names( + Derivation.DEVICE_CONFIG_NAMESPACE, + Namespace.DEVICE_CONFIG, + listOf( + "whoop_live_hrv_in_adv_ind_pkt", "whoop_live_spo2_in_adv_ind_pkt", + "whoop_live_temp_in_adv_ind_pkt", "whoop_live_ecg_in_adv_ind_pkt", + ), + ) + + /** + * Names ALREADY ANSWERED `FAILURE(0)` by a real WHOOP 5 MG (WS50_r03) — the firmware has no key by any + * of them. Kept OUT of [CATALOGUE] so nobody spends round-trips re-asking, and kept here rather than + * deleted so nobody proposes them again. + * + * They are also the evidence for how [CATALOGUE] is built: all eight are product English ("blood + * oxygen", "pulse ox", "subscription"), and all eight are wrong. + */ + val RETIRED_KEYS: List = listOf( + "enable_spo2", + "enable_spo2_packets", + "spo2_enable", + "enable_blood_oxygen", + "blood_oxygen_enable", + "enable_pulse_ox", + "enable_oxygen_packets", + "spo2_subscription_enabled", + ) + + /** + * How many candidate names one run may test. Bounds the wall clock: with the read verbs live a + * round-trip is one BLE write plus one notification, so a whole run stays inside a couple of minutes. + * [CATALOGUE] is smaller than this today, so every run tests all of it; the batching exists so a + * catalogue GROWN past the budget truncates VISIBLY and resumably instead of silently. + */ + const val MAX_KEYS_PER_RUN = 64 + + /** One run's slice of the catalogue. */ + data class Batch( + /** The candidates this run may test, in order. */ + val candidates: List, + /** Zero-based index of the first candidate in the slice (the report shows it 1-based). */ + val start: Int, + /** Cursor to hand the NEXT run. Wraps to 0 once a slice reaches the end of the catalogue. */ + val nextCursor: Int, + ) { + /** Names in the catalogue this run does not reach. */ + val remaining: Int get() = CATALOGUE.size - start - candidates.size + + /** True when this slice ends at the end of the catalogue. */ + val completesCatalogue: Boolean get() = remaining == 0 + } + + /** + * The slice to test starting at [cursor]. A cursor outside the catalogue — negative, or left over from + * a longer catalogue — restarts at 0 rather than wasting a run. A slice never wraps mid-batch: it + * stops at the end and hands back 0, so no name is asked twice in one run. + * + * [limit] defaults to [MAX_KEYS_PER_RUN] and is a parameter only so tests can exercise the + * truncate-and-resume path today, while the catalogue is still smaller than one run's budget. + */ + fun batch(cursor: Int, limit: Int = MAX_KEYS_PER_RUN): Batch { + if (CATALOGUE.isEmpty() || limit <= 0) return Batch(emptyList(), 0, 0) + val start = if (cursor < 0 || cursor >= CATALOGUE.size) 0 else cursor + val end = minOf(start + limit, CATALOGUE.size) + return Batch(CATALOGUE.subList(start, end).toList(), start, if (end >= CATALOGUE.size) 0 else end) + } +} diff --git a/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt b/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt index e46ff031a4..c2ce5759c4 100644 --- a/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt +++ b/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt @@ -25,16 +25,23 @@ package com.noop.protocol * * ## What this establishes, and the honest failure case * - * **Both target opcodes may simply not be implemented in firmware.** The repo's own protocol table - * (`whoop_protocol.json`, [CommandNumber]) names 121 `GET_DEVICE_CONFIG_VALUE` and 128 `GET_FF_VALUE`, - * but a name in a table is not a served verb: opcode 96 (`ENTER_HIGH_FREQ_HISTORICAL_MODE`) is a - * standing example of a number the table carries that nothing in the wild sends. So the probe's PRIMARY - * deliverable is a clean verdict per verb — **answered**, **rejected as UNSUPPORTED**, or **silent** — - * and "both verbs are unimplemented" is a useful, publishable result, not a failure. + * Both read verbs answered on a real WHOOP 5 MG (WS50_r03), and the reply turned out to be an + * **existence oracle**: `SUCCESS(1)` for a key name the firmware has, `FAILURE(0)` for one it does not. + * That is what makes a key-name search possible at all, and [ConfigKeySweep] is where it lives. * - * Only if a verb answers does the probe go on to read values: first the sixteen key names NOOP already - * has (their VALUES on a real strap have never been read, only written), then a short list of GUESSED - * oxygen-related key names against the device-config namespace. + * The probe now runs a plan built around one principle: **ask the strap before guessing.** It opens with + * the DEVICE-CONFIG ENUMERATION pair `START_DEVICE_CONFIG_KEY_EXCHANGE` (115) and + * `SEND_NEXT_DEVICE_CONFIG` (116) — named in the repo's own `CommandNumber` table, never sent by anything + * here, and the structural twin of the 117/118 feature-flag pair #872 shipped and a strap answered. If + * 115/116 answer, the strap lists its own device-config keys and no name needs guessing at all; the + * candidate sweep is skipped and the report says so. A clean "115/116 are not served" is equally useful + * and publishable — it is what promotes the guessing fallback from a shortcut to the only available + * method. + * + * After enumeration the probe reads the values of keys already known to exist (the sixteen in + * [Whoop5Config.enableR22Sequence], plus anything enumeration returned), spends two round-trips + * establishing whether the two namespaces are actually separate, and only then — and only when + * enumeration produced nothing — asks the guessed names in [ConfigKeySweep.CATALOGUE]. * * ## Read-only by construction * @@ -76,9 +83,17 @@ object DeviceConfigReadProbe { /** `SET_FF_VALUE` (120 / 0x78). Named ONLY so the allowlist can name what it excludes. */ const val SET_FEATURE_FLAG_VALUE_CMD = 120 - /** The complete set of opcodes this probe may put on the wire. The BLE send path admits these two - * and only these two while a probe is in flight; [isReadOnlyOpcode] is the predicate it asks. */ - val READ_ONLY_OPCODES = setOf(GET_DEVICE_CONFIG_VALUE_CMD, GET_FEATURE_FLAG_VALUE_CMD) + /** The complete set of opcodes this probe may put on the wire: the two VALUE reads above, plus the + * two DEVICE-CONFIG ENUMERATION verbs the probe now tries first ([ConfigKeySweep], 115/116 — the + * structural twins of the 117/118 pair #872 shipped and a real strap answered read-only). The BLE + * send path admits these four and only these four while a probe is in flight; [isReadOnlyOpcode] is + * the predicate it asks, and unit tests prove it rejects 119, 120 and every other opcode. */ + val READ_ONLY_OPCODES = setOf( + GET_DEVICE_CONFIG_VALUE_CMD, + GET_FEATURE_FLAG_VALUE_CMD, + ConfigKeySweep.START_DEVICE_CONFIG_KEY_EXCHANGE_CMD, + ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD, + ) /** The config WRITE verbs, which this probe must never emit. Kept as a named set so the read-only * contract is testable as a property of the allowlist rather than as a claim in a comment. */ @@ -90,37 +105,19 @@ object DeviceConfigReadProbe { /** Width of the key-name field in the SET bodies (both NUL-pad the name to 32 bytes). */ const val NAME_FIELD_BYTES = 32 - /** Hard ceiling on round-trips in one probe, independent of how many keys the plan holds. */ - const val MAX_STEPS = 64 + /** Hard ceiling on round-trips in one probe, independent of how many keys the plan holds. The plan is + * 1 enumerate-start + up to [ConfigKeySweep.MAX_ENUMERATION_STEPS] enumerate-next + 2 discovery + 2 + * cross-namespace + 16 known flags, then EITHER up to [ConfigKeySweep.MAX_ENUMERATED_VALUE_READS] + * value reads (when enumeration produced a list) OR up to [ConfigKeySweep.MAX_KEYS_PER_RUN] candidate + * names (when it did not) — never both, because guessing is pointless once the strap has handed over + * its own list. Worst case is 101 round-trips, comfortably under this. */ + const val MAX_STEPS = 128 /** The one device-config key NOOP already knows a real strap accepts: the Broadcast-HR flag written * via SET_DEVICE_CONFIG_VALUE and hardware-validated in #181. Used as the discovery key for opcode * 121 precisely because it is known-good — a FAILURE on this key is evidence about the verb. */ const val DEVICE_CONFIG_DISCOVERY_KEY = "whoop_live_hr_in_adv_ind_pkt" - /** - * **GUESSES.** Candidate oxygen-related key names to try against the device-config namespace. None of - * these has been observed on a wire, in a capture, or in any protocol table — they are constructed - * from the naming conventions the *known* keys follow (`enable_…`, `…_enable`, snake_case, and the - * `whoop_…` prefix the one known device-config key uses). They are reported as guesses everywhere - * they appear. - * - * This list is the one place to extend. Adding a name here adds a probe step and nothing else — the - * same pattern would let a future run try, say, `enable_sig13` (the undocumented `enable_sig11…` / - * `enable_sig12` series continued) without touching any other code. Keep in lockstep with the Swift - * `DeviceConfigReadProbe.oxygenCandidateKeys`. - */ - val OXYGEN_CANDIDATE_KEYS: List = listOf( - "enable_spo2", - "enable_spo2_packets", - "spo2_enable", - "enable_blood_oxygen", - "blood_oxygen_enable", - "enable_pulse_ox", - "enable_oxygen_packets", - "spo2_subscription_enabled", - ) - /** COMMAND_RESPONSE packet type (36 / 0x24). */ private const val COMMAND_RESPONSE_TYPE = 36 @@ -156,6 +153,12 @@ object DeviceConfigReadProbe { /** The firmware answered but reported failure: the verb exists, the request did not satisfy it. */ val isFailure: Boolean get() = resultCode == 0 + /** What this reply says about whether the key NAME exists, per the oracle a real WHOOP 5 MG + * established: `SUCCESS(1)` = the firmware has this key, `FAILURE(0)` = it does not, anything else + * (and WHOOP 4.0, where the result byte's meaning is not pinned here) = inconclusive. + * Deliberately reads the RESULT CODE and nothing else — no inference from the record bytes. */ + val existence: ConfigKeySweep.Existence get() = ConfigKeySweep.existence(resultCode) + /** Raw record bytes as lowercase space-separated hex; always reported, whatever else decodes. */ val recordHex: String get() = hex(record) @@ -249,35 +252,54 @@ object DeviceConfigReadProbe { } /** - * The running result of one device-config read probe: the plan it walks, the per-verb verdict it - * reaches, the values it manages to read, and the copyable transcript. Pure and order-dependent - * (`nextStep` → `note…` → `nextStep` → …). Twin of Swift `DeviceConfigReadProbeReport`; [render] is - * byte-identical across platforms. + * The running result of one config probe: the strap's own device-config key list when it will give one, + * the per-verb verdict, the values read, the candidate sweep when guessing is still necessary, and the + * copyable transcript. Pure and order-dependent (`nextStep` → `note…` → `nextStep` → …). Twin of Swift + * `DeviceConfigReadProbeReport`; [render] is byte-identical across platforms. + * + * The plan is ordered so the cheapest decisive question is asked first: + * + * 1. **ENUMERATE** — 115 then repeated 116. If the strap answers, it has just listed its own + * device-config keys and no name needs guessing. + * 2. **DISCOVERY** — one 128 read and one 121 read, each against a key that verb should know. + * 3. **CROSS_NAMESPACE** — ask each verb for the OTHER namespace's known key. Settles in two round-trips + * whether the namespaces are really separate. + * 4. **KNOWN_KEY** — read the values of the sixteen flags NOOP writes, plus anything enumeration produced. + * 5. **CANDIDATE** — the guessed-name sweep, and **only when enumeration produced no list**. */ class DeviceConfigReadProbeReport( private val family: DeviceFamily, /** The flag names whose values to read — supplied by the caller from [Whoop5Config.enableR22Sequence] * so this file never restates them. */ private val knownFlagKeys: List, - /** The guessed oxygen key names — supplied by the caller from [DeviceConfigReadProbe]. */ - private val candidateKeys: List, + /** This run's slice of the candidate catalogue, and the cursor to hand the next run. */ + val batch: ConfigKeySweep.Batch, ) { /** Which part of the plan a step belongs to. Drives both the ordering and the report's sections. */ - enum class Group { DISCOVERY, KNOWN_FLAG, CANDIDATE } + enum class Group { ENUMERATE, DISCOVERY, CROSS_NAMESPACE, KNOWN_KEY, CANDIDATE } - /** One planned round-trip. */ - data class Step(val opcode: Int, val key: String, val group: Group) + /** One planned round-trip. [derivation] is set only for candidate steps. */ + data class Step( + val opcode: Int, + val key: String, + val group: Group, + val derivation: ConfigKeySweep.Derivation? = null, + ) - /** What one verb has been shown to do. `UNTRIED` until its discovery step resolves. */ + /** What one verb has been shown to do. `UNTRIED` until its first step resolves. */ enum class VerbStatus(val label: String) { UNTRIED("untried"), + /** A decodable COMMAND_RESPONSE came back and was not an explicit UNSUPPORTED. */ ANSWERED("answered"), + /** The firmware refused the opcode (5/MG result code 3). */ UNSUPPORTED("unsupported"), + /** No reply inside the probe's per-step window. */ SILENT("silent"), + /** A reply arrived but could not be decoded (CRC, envelope, or a short record). */ UNDECODABLE("undecodable"), } @@ -291,7 +313,11 @@ class DeviceConfigReadProbeReport( val value: Int?, val resultCode: Int?, val recordHex: String, - ) + val derivation: ConfigKeySweep.Derivation? = null, + ) { + /** The oracle's verdict on whether this key NAME exists. */ + val existence: ConfigKeySweep.Existence get() = ConfigKeySweep.existence(resultCode) + } /** Status of `GET_FF_VALUE` (128). */ var featureFlagVerb: VerbStatus = VerbStatus.UNTRIED @@ -301,12 +327,42 @@ class DeviceConfigReadProbeReport( var deviceConfigVerb: VerbStatus = VerbStatus.UNTRIED private set + /** Status of the device-config ENUMERATION pair (115/116), taken as one verb: 116 cannot be asked + * without 115 having answered, so a single verdict describes the pair. */ + var enumerationVerb: VerbStatus = VerbStatus.UNTRIED + private set + + private val _enumeratedKeys = mutableListOf() + + /** Device-config key names the strap listed for itself. The headline result when it is non-empty. */ + val enumeratedKeys: List get() = _enumeratedKeys + + /** The key count `START_DEVICE_CONFIG_KEY_EXCHANGE` announced, when it answered. */ + var enumeratedCount: Int? = null + private set + + /** Entries the strap called real keys whose NAME did not decode, stepped over rather than trusted as a + * terminator (the discipline #874 established for the 117/118 walk). */ + var enumerationSkipped: Int = 0 + private set + + /** `GET_FF_VALUE(128)` asked for the known DEVICE-CONFIG key: does the flag verb see that namespace? */ + var featureFlagVerbOnDeviceConfigKey: ConfigKeySweep.Existence? = null + private set + + /** `GET_DEVICE_CONFIG_VALUE(121)` asked for a known FLAG key: does that verb see the other namespace? */ + var deviceConfigVerbOnFlagKey: ConfigKeySweep.Existence? = null + private set + private val _readings = mutableListOf() + /** Every reading, in the order the strap served it. */ val readings: List get() = _readings private val _trace = mutableListOf() - /** Trace lines: one per round-trip plus any failure notes. */ + + /** Trace lines. Candidate round-trips are summarised in their own section rather than repeated here, + * EXCEPT the ones that are not a plain `unknown` — a hit or an odd reply always appears in full. */ val trace: List get() = _trace /** Round-trips attempted. Bounds the walk against [DeviceConfigReadProbe.MAX_STEPS]. */ @@ -317,9 +373,12 @@ class DeviceConfigReadProbeReport( var stopReason: String? = null private set - private var phase = 0 // 0 discovery, 1 known flags, 2 candidates, 3 done + private var phase = 0 // 0 enumerate, 1 discovery, 2 cross, 3 known keys, 4 candidates, 5 done private var cursor = 0 - /** `"opcode:key"` pairs already attempted, so discovery's key is not re-read in a later phase. */ + private var enumPhase = 0 // 0 send 115, 1 send 116 repeatedly, 2 done + private var enumSteps = 0 + + /** `"opcode:key"` pairs already attempted, so an earlier phase's key is not re-read in a later one. */ private val attempted = mutableSetOf() /** @@ -331,16 +390,20 @@ class DeviceConfigReadProbeReport( if (stopReason == null) { stopReason = "safety cap of ${DeviceConfigReadProbe.MAX_STEPS} round-trips reached" } - phase = 3 + phase = 5 return null } - while (phase < 3) { + while (phase < 5) { val step = stepInCurrentPhase() if (step != null) { cursor += 1 - val id = "${step.opcode}:${step.key}" - if (attempted.contains(id)) continue - attempted.add(id) + // Enumeration deliberately repeats one (opcode, key) pair — the strap walks its own + // cursor — so it is the one group the de-duplicator must not police. + if (step.group != Group.ENUMERATE) { + val id = "${step.opcode}:${step.key}" + if (attempted.contains(id)) continue + attempted.add(id) + } steps += 1 return step } @@ -350,11 +413,29 @@ class DeviceConfigReadProbeReport( return null } - /** One candidate step from the current phase, or null when that phase is exhausted. */ + /** One step from the current phase, or null when that phase is exhausted. */ private fun stepInCurrentPhase(): Step? = when (phase) { - 0 -> { - // Discovery: one round-trip per verb, each against a key that verb has a reason to know. - // 128 gets a flag NOOP writes; 121 gets the Broadcast-HR key, hardware-validated in #181. + 0 -> when (enumPhase) { + // Ask the strap to list its own device-config keys. 115 once; then 116 until the strap says + // stop, exactly as the 117/118 walk does. + 0 -> Step(ConfigKeySweep.START_DEVICE_CONFIG_KEY_EXCHANGE_CMD, "", Group.ENUMERATE) + 1 -> if (enumSteps >= ConfigKeySweep.MAX_ENUMERATION_STEPS) { + if (stopReason == null) { + stopReason = "device-config enumeration hit its cap of " + + "${ConfigKeySweep.MAX_ENUMERATION_STEPS} entries; the rest of the plan still ran" + } + enumPhase = 2 + null + } else { + enumSteps += 1 + Step(ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD, "", Group.ENUMERATE) + } + else -> null + } + 1 -> { + // Discovery: one round-trip per VALUE verb, each against a key that verb has a reason to know. + // 128 gets a flag NOOP writes; 121 gets the Broadcast-HR key NOOP has written since #181, so a + // FAILURE there is evidence about the VERB, not about the key. val plan = listOf( Step( DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD, @@ -369,53 +450,171 @@ class DeviceConfigReadProbeReport( ) if (cursor < plan.size) plan[cursor] else null } - 1 -> { - // Known flag values, through whichever verb answered — 128 by preference (it owns the - // feature-flag namespace), 121 as a fallback worth one look if only it survived. - val verb = verbForFlags() - if (verb != null && cursor < knownFlagKeys.size) { - Step(verb, knownFlagKeys[cursor], Group.KNOWN_FLAG) - } else { - null + 2 -> { + // Cross-namespace: each answering verb asked for the OTHER namespace's known-good key. Two + // round-trips that settle whether the namespaces are actually separate. + val plan = mutableListOf() + if (featureFlagVerb == VerbStatus.ANSWERED) { + plan.add( + Step( + DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD, + DeviceConfigReadProbe.DEVICE_CONFIG_DISCOVERY_KEY, + Group.CROSS_NAMESPACE, + ), + ) + } + val flag = knownFlagKeys.firstOrNull() + if (deviceConfigVerb == VerbStatus.ANSWERED && flag != null) { + plan.add( + Step(DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD, flag, Group.CROSS_NAMESPACE), + ) } + if (cursor < plan.size) plan[cursor] else null } - 2 -> { - // Guessed oxygen keys, through the device-config verb by preference — that namespace is the - // one this probe exists to reach. - val verb = verbForCandidates() - if (verb != null && cursor < candidateKeys.size) { - Step(verb, candidateKeys[cursor], Group.CANDIDATE) + 3 -> { + val plan = knownKeyPlan() + if (cursor >= plan.size) { + null } else { + val entry = plan[cursor] + val verb = verbFor(entry.second) + if (verb == null) null else Step(verb, entry.first, Group.KNOWN_KEY) + } + } + 4 -> { + // Guessing is the FALLBACK. If the strap enumerated its own device-config keys there is + // nothing to guess at in that namespace, so the sweep is skipped and said so in the report. + if (_enumeratedKeys.isNotEmpty() || cursor >= batch.candidates.size) { null + } else { + val candidate = batch.candidates[cursor] + val verb = verbFor(candidate.namespace) + if (verb == null) { + null + } else { + Step(verb, candidate.key, Group.CANDIDATE, candidate.derivation) + } } } else -> null } - /** The verb to read feature-flag values through, or null when neither answered. */ - private fun verbForFlags(): Int? = when { - featureFlagVerb == VerbStatus.ANSWERED -> DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD - deviceConfigVerb == VerbStatus.ANSWERED -> DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD - else -> null + /** + * The keys whose values are worth reading because they are already known to exist: the sixteen flags + * NOOP writes, then whatever the strap enumerated for itself (capped, and never re-listing a flag). + */ + private fun knownKeyPlan(): List> { + val plan = knownFlagKeys.map { it to ConfigKeySweep.Namespace.FEATURE_FLAG }.toMutableList() + for (key in _enumeratedKeys.take(ConfigKeySweep.MAX_ENUMERATED_VALUE_READS)) { + if (!knownFlagKeys.contains(key)) plan.add(key to ConfigKeySweep.Namespace.DEVICE_CONFIG) + } + return plan } - /** The verb to try guessed device-config keys through, or null when neither answered. */ - private fun verbForCandidates(): Int? = when { - deviceConfigVerb == VerbStatus.ANSWERED -> DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD - featureFlagVerb == VerbStatus.ANSWERED -> DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD - else -> null + /** + * The verb to ask a key of, or null when neither VALUE verb answered. + * + * A verb SHOWN in this same run to serve the other namespace too is preferred for everything — fewer + * moving parts, and the evidence is from this run rather than an assumption. Otherwise each namespace + * uses its own verb, falling back to the other one as a look worth taking. + */ + private fun verbFor(namespace: ConfigKeySweep.Namespace): Int? { + if (deviceConfigVerbOnFlagKey == ConfigKeySweep.Existence.EXISTS && + deviceConfigVerb == VerbStatus.ANSWERED + ) { + return DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD + } + if (featureFlagVerbOnDeviceConfigKey == ConfigKeySweep.Existence.EXISTS && + featureFlagVerb == VerbStatus.ANSWERED + ) { + return DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD + } + val ff = if (featureFlagVerb == VerbStatus.ANSWERED) { + DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD + } else { + null + } + val dc = if (deviceConfigVerb == VerbStatus.ANSWERED) { + DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD + } else { + null + } + return if (namespace == ConfigKeySweep.Namespace.FEATURE_FLAG) ff ?: dc else dc ?: ff } - /** Record one decoded reply. */ + /** + * Record the `START_DEVICE_CONFIG_KEY_EXCHANGE` reply. An implausible count is reported but never + * trusted as a loop bound — the walk is bounded by [ConfigKeySweep.MAX_ENUMERATION_STEPS] and by the + * strap's own end marker. + */ + fun noteEnumerationStart(r: FeatureFlagProbe.StartResponse) { + enumeratedCount = r.count + if (r.resultCode == 3) { + enumerationVerb = VerbStatus.UNSUPPORTED + enumPhase = 2 + _trace.add( + "START_DEVICE_CONFIG_KEY_EXCHANGE(115) → result=UNSUPPORTED(3) — " + + "the firmware does not serve this verb", + ) + return + } + enumerationVerb = VerbStatus.ANSWERED + enumPhase = 1 + var line = "START_DEVICE_CONFIG_KEY_EXCHANGE(115) →" + val code = r.resultCode + if (code != null) line += " result=${FeatureFlagProbe.resultLabel(code)}($code)" + line += " revision=${r.revision} count=${r.count}" + if (!r.countIsPlausible) line += " (implausible — walked to the strap's own end marker instead)" + _trace.add(line) + } + + /** + * Record one `SEND_NEXT_DEVICE_CONFIG` reply. Returns true when the walk should continue. + * + * Mirrors the #874 discipline on the 117/118 walk: the strap's own end marker terminates the walk, but + * a name OUR parser declines ([FeatureFlagProbe.NextResponse.isSkippable]) is counted and stepped over + * — one undecodable entry must not throw away every key after it. + */ + fun noteEnumerationNext(r: FeatureFlagProbe.NextResponse): Boolean { + if (r.isExhausted) { + enumPhase = 2 + _trace.add("SEND_NEXT_DEVICE_CONFIG(116) → end of list (index=${r.index} validKey=${r.validKey})") + return false + } + if (r.isSkippable) { + enumerationSkipped += 1 + _trace.add("SEND_NEXT_DEVICE_CONFIG(116) → index=${r.index} name did not decode — stepped over") + return true + } + val key = r.key + if (key != null) { + _enumeratedKeys.add(key) + _trace.add("SEND_NEXT_DEVICE_CONFIG(116) → index=${r.index} key=\"$key\"") + } + return true + } + + /** Record one decoded VALUE reply. */ fun noteReply(r: DeviceConfigReadProbe.ValueResponse, step: Step) { setStatus(if (r.isUnsupported) VerbStatus.UNSUPPORTED else VerbStatus.ANSWERED, step.opcode) + if (step.group == Group.CROSS_NAMESPACE) { + if (step.opcode == DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD) { + featureFlagVerbOnDeviceConfigKey = r.existence + } else if (step.opcode == DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD) { + deviceConfigVerbOnFlagKey = r.existence + } + } val value = r.valueFor(step.key) _readings.add( - Reading(step.group, step.opcode, step.key, value, r.resultCode, r.recordHex), + Reading(step.group, step.opcode, step.key, value, r.resultCode, r.recordHex, step.derivation), ) + // The candidate section lists every name it asked, so repeating a plain "unknown" in the + // transcript would double the report for no information. Anything else is always traced. + if (step.group == Group.CANDIDATE && r.existence == ConfigKeySweep.Existence.UNKNOWN) return var line = "${opcodeLabel(step.opcode)} key=\"${step.key}\"" val code = r.resultCode line += if (code != null) " → result=${FeatureFlagProbe.resultLabel(code)}($code)" else " →" + line += " ${r.existence.label}" if (value != null) line += " value=${DeviceConfigReadProbe.valueLabel(value)}" line += " record=[${r.recordHex}]" _trace.add(line) @@ -448,28 +647,63 @@ class DeviceConfigReadProbeReport( * so one lucky reply after an UNSUPPORTED cannot rewrite the headline. */ private fun setStatus(s: VerbStatus, opcode: Int) { - if (opcode == DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD) { - if (featureFlagVerb == VerbStatus.UNTRIED || featureFlagVerb == VerbStatus.ANSWERED) { - featureFlagVerb = s - } - } else if (opcode == DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD) { - if (deviceConfigVerb == VerbStatus.UNTRIED || deviceConfigVerb == VerbStatus.ANSWERED) { - deviceConfigVerb = s + when (opcode) { + DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD -> + if (featureFlagVerb == VerbStatus.UNTRIED || featureFlagVerb == VerbStatus.ANSWERED) { + featureFlagVerb = s + } + DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD -> + if (deviceConfigVerb == VerbStatus.UNTRIED || deviceConfigVerb == VerbStatus.ANSWERED) { + deviceConfigVerb = s + } + ConfigKeySweep.START_DEVICE_CONFIG_KEY_EXCHANGE_CMD, + ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD, + -> { + if (enumerationVerb == VerbStatus.UNTRIED || enumerationVerb == VerbStatus.ANSWERED) { + enumerationVerb = s + } + if (s != VerbStatus.ANSWERED) enumPhase = 2 } + else -> Unit } } /** Short opcode label used in the transcript. */ - private fun opcodeLabel(opcode: Int): String = - if (opcode == DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD) { - "GET_DEVICE_CONFIG_VALUE(121)" - } else { - "GET_FF_VALUE(128)" + private fun opcodeLabel(opcode: Int): String = when (opcode) { + DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD -> "GET_DEVICE_CONFIG_VALUE(121)" + ConfigKeySweep.START_DEVICE_CONFIG_KEY_EXCHANGE_CMD -> "START_DEVICE_CONFIG_KEY_EXCHANGE(115)" + ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD -> "SEND_NEXT_DEVICE_CONFIG(116)" + else -> "GET_FF_VALUE(128)" + } + + /** Candidate readings only. */ + private val candidateReadings: List get() = _readings.filter { it.group == Group.CANDIDATE } + + /** + * Every key name this run proved EXISTS that NOOP did not already have — the whole point of the + * exercise. Enumerated names count; so does any candidate the oracle confirmed. + */ + val newKeysFound: List + get() { + val out = _enumeratedKeys.filter { + !knownFlagKeys.contains(it) && it != DeviceConfigReadProbe.DEVICE_CONFIG_DISCOVERY_KEY + }.toMutableList() + for (r in candidateReadings) { + if (r.existence == ConfigKeySweep.Existence.EXISTS && !out.contains(r.key)) out.add(r.key) + } + return out } /** One-line summary of what the probe established. */ val verdict: String get() { + val found = newKeysFound + if (found.isNotEmpty()) { + return "${found.size} config key name(s) found that NOOP did not have: ${found.joinToString(", ")}" + } + if (enumerationVerb == VerbStatus.ANSWERED) { + return "the strap enumerated its device-config namespace and returned no key NOOP did not already have" + } val answered = listOf(featureFlagVerb, deviceConfigVerb).count { it == VerbStatus.ANSWERED } if (answered == 0) { val both = @@ -482,57 +716,166 @@ class DeviceConfigReadProbeReport( } return both } - val named = _readings.count { it.value != null } - if (named == 0) { - return "$answered of 2 read verbs answered, but no reply echoed its key so no value is claimed" + val asked = candidateReadings.size + if (asked == 0) { + return "$answered of 2 read verbs answered; no candidate name was asked" + } + val unknown = candidateReadings.count { it.existence == ConfigKeySweep.Existence.UNKNOWN } + if (unknown == asked) { + return "asked $asked candidate key name(s); this firmware has none of them (a clean negative)" } - return "$answered of 2 read verbs answered; read $named config value(s)" + return "asked $asked candidate key name(s); $unknown do not exist, ${asked - unknown} inconclusive" } /** The full copyable report (byte-identical to the Swift `render()`). */ fun render(): String { val fam = if (family == DeviceFamily.WHOOP5) "WHOOP 5/MG" else "WHOOP 4.0" val sb = StringBuilder() - sb.append("#103 DEVICE-CONFIG READ PROBE — $fam\n") - sb.append("Read-only: GET_DEVICE_CONFIG_VALUE(121) + GET_FF_VALUE(128). No value is written; ") - sb.append("SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path.\n") - sb.append("Follow-up to the #761 enumeration probe: that one asked for key NAMES, this asks for VALUES.\n") + sb.append("#103 CONFIG KEY PROBE — $fam\n") + sb.append("Read-only: START_DEVICE_CONFIG_KEY_EXCHANGE(115), SEND_NEXT_DEVICE_CONFIG(116), ") + sb.append("GET_DEVICE_CONFIG_VALUE(121), GET_FF_VALUE(128).\n") + sb.append( + "No value is written; SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path.\n", + ) + sb.append( + "Oracle: result=SUCCESS(1) means the key NAME exists; result=FAILURE(0) means the firmware has no such key.\n", + ) sb.append("\nVerdict: $verdict\n") stopReason?.let { sb.append("Stopped: $it\n") } - sb.append("\nRead verbs:\n") - sb.append(" ").append(DeviceConfigReadProbe.padded("GET_FF_VALUE(128)", 30)) + sb.append("\nVerbs:\n") + sb.append(" ").append(DeviceConfigReadProbe.padded("device-config enumerate(115/116)", 34)) + .append(enumerationVerb.label).append("\n") + sb.append(" ").append(DeviceConfigReadProbe.padded("GET_FF_VALUE(128)", 34)) .append(featureFlagVerb.label).append("\n") - sb.append(" ").append(DeviceConfigReadProbe.padded("GET_DEVICE_CONFIG_VALUE(121)", 30)) + sb.append(" ").append(DeviceConfigReadProbe.padded("GET_DEVICE_CONFIG_VALUE(121)", 34)) .append(deviceConfigVerb.label).append("\n") + sb.append(enumerationSection()) + sb.append(namespaceSection()) sb.append( section( Group.DISCOVERY, - "Discovery — one round-trip per verb against a key it should know", + "Discovery — one round-trip per value verb against a key it should know", "(none — no reply was decoded)", ), ) sb.append( section( - Group.KNOWN_FLAG, - "Known feature-flag values (names NOOP already writes; values never read before)", - "(none — the verb that would carry them did not answer)", - ), - ) - sb.append( - section( - Group.CANDIDATE, - "Candidate oxygen keys — GUESSES, never observed on a wire or in any table", - "(none — the verb that would carry them did not answer)", + Group.KNOWN_KEY, + "Known key values (the flags NOOP writes, plus anything enumeration returned)", + "(none — no value verb answered)", ), ) + sb.append(candidateSection()) sb.append("\nExchange:\n") for (line in _trace) sb.append(" ").append(line).append("\n") return sb.toString() } + /** The strap's own device-config key list — the result that makes guessing unnecessary. */ + private fun enumerationSection(): String { + val sb = StringBuilder() + sb.append("\nDevice-config keys the strap listed for itself (115/116) (${_enumeratedKeys.size}):\n") + if (_enumeratedKeys.isEmpty()) { + sb.append( + when (enumerationVerb) { + VerbStatus.UNSUPPORTED -> " (none — the firmware refused 115 as UNSUPPORTED)\n" + VerbStatus.SILENT -> " (none — no reply to 115)\n" + VerbStatus.UNDECODABLE -> " (none — the reply did not decode)\n" + VerbStatus.ANSWERED -> " (none — 115 answered but the walk produced no names)\n" + VerbStatus.UNTRIED -> " (none — not reached)\n" + }, + ) + return sb.toString() + } + _enumeratedKeys.forEachIndexed { i, key -> + sb.append(" %2d. ".format(i + 1)).append(key).append("\n") + } + if (enumerationSkipped > 0) { + sb.append( + " ($enumerationSkipped further entr(ies) the strap called real but whose name did not decode)\n", + ) + } + val announced = enumeratedCount + if (announced != null && announced != _enumeratedKeys.size + enumerationSkipped) { + sb.append( + " (the strap announced $announced; the walk served ${_enumeratedKeys.size + enumerationSkipped})\n", + ) + } + return sb.toString() + } + + /** Whether the two namespaces are really separate — two round-trips that shape every future sweep. */ + private fun namespaceSection(): String { + val sb = StringBuilder() + sb.append("\nNamespace separation:\n") + val ffLabel = featureFlagVerbOnDeviceConfigKey?.label ?: "not asked" + val dcLabel = deviceConfigVerbOnFlagKey?.label ?: "not asked" + sb.append(" ").append(DeviceConfigReadProbe.padded("128 asked for a device-config key", 38)) + .append(ffLabel).append("\n") + sb.append(" ").append(DeviceConfigReadProbe.padded("121 asked for a feature-flag key", 38)) + .append(dcLabel).append("\n") + sb.append( + when { + featureFlagVerbOnDeviceConfigKey == ConfigKeySweep.Existence.EXISTS -> + " ⇒ GET_FF_VALUE(128) serves BOTH namespaces.\n" + deviceConfigVerbOnFlagKey == ConfigKeySweep.Existence.EXISTS -> + " ⇒ GET_DEVICE_CONFIG_VALUE(121) serves BOTH namespaces.\n" + featureFlagVerbOnDeviceConfigKey == ConfigKeySweep.Existence.UNKNOWN && + deviceConfigVerbOnFlagKey == ConfigKeySweep.Existence.UNKNOWN -> + " ⇒ the namespaces are separate: neither verb sees the other's keys.\n" + else -> " ⇒ inconclusive.\n" + }, + ) + return sb.toString() + } + + /** The candidate sweep, grouped by derivation, with the tested/untested arithmetic spelled out. */ + private fun candidateSection(): String { + val rows = candidateReadings + val tested = rows.size + val total = ConfigKeySweep.CATALOGUE.size + val untested = total - batch.start - tested + val sb = StringBuilder() + sb.append("\nCandidate key names — GUESSES, never observed on a wire or in any table") + sb.append(" ($tested asked of $total in the catalogue") + sb.append(if (untested > 0) "; $untested untested" else "; none untested") + sb.append("):\n") + if (rows.isEmpty()) { + sb.append( + when { + _enumeratedKeys.isNotEmpty() -> + " (skipped — the strap enumerated its own device-config keys, so nothing needs guessing)\n" + featureFlagVerb != VerbStatus.ANSWERED && deviceConfigVerb != VerbStatus.ANSWERED -> + " (none — no value verb answered, so no name could be asked)\n" + else -> " (none asked)\n" + }, + ) + return sb.toString() + } + val exists = rows.count { it.existence == ConfigKeySweep.Existence.EXISTS } + val unknown = rows.count { it.existence == ConfigKeySweep.Existence.UNKNOWN } + sb.append(" $exists exist · $unknown do not · ${tested - exists - unknown} inconclusive\n") + for (derivation in ConfigKeySweep.Derivation.entries) { + val group = rows.filter { it.derivation == derivation } + if (group.isEmpty()) continue + sb.append("\n ${derivation.title} (${group.size}):\n") + group.forEachIndexed { i, r -> + sb.append(" %2d. ".format(i + 1)).append(DeviceConfigReadProbe.padded(r.key, 32)) + .append(r.existence.label) + val v = r.value + if (v != null) sb.append(" = ").append(DeviceConfigReadProbe.valueLabel(v)) + sb.append("\n") + } + } + if (untested > 0) { + sb.append("\n Run the probe again to continue from catalogue entry ${batch.nextCursor + 1}.\n") + } + return sb.toString() + } + /** One rendered section of readings. */ private fun section(group: Group, title: String, empty: String): String { val rows = _readings.filter { it.group == group } diff --git a/android/app/src/main/java/com/noop/protocol/Enums.kt b/android/app/src/main/java/com/noop/protocol/Enums.kt index 782b7b18a4..ec4f44f85b 100644 --- a/android/app/src/main/java/com/noop/protocol/Enums.kt +++ b/android/app/src/main/java/com/noop/protocol/Enums.kt @@ -212,6 +212,19 @@ enum class CommandNumber(val rawValue: Int) { // #690: read-only body-location/status probe. Documented in the WHOOP protocol; driven only by the // user-triggered, Test-Centre-gated probeBodyLocationAndStatus(). Decoded to a diagnostic report only. GET_BODY_LOCATION_AND_STATUS(84), + // START_DEVICE_CONFIG_KEY_EXCHANGE (115 / 0x73) — READ-ONLY: ask the strap how many DEVICE-CONFIG keys + // its firmware knows. Payload [0x01]; the reply carries a count and nothing on the strap changes. The + // device-config twin of 117, and the half of the config surface nothing here has ever sent: the + // CommandNumber table names 115/116 alongside 119/121, and only 119 (write) and 121 (read one value) + // were implemented. If it answers, the strap lists its own device-config keys and #103 stops needing to + // guess names. Driven ONLY by WhoopBleClient.probeDeviceConfigValues() — user-initiated, Test Centre + // gated. Parsing reuses FeatureFlagProbe.parseStart on the assumed-symmetric layout. (#103) + START_DEVICE_CONFIG_KEY_EXCHANGE(115), + // SEND_NEXT_DEVICE_CONFIG (116 / 0x74) — READ-ONLY: advance the strap's own device-config cursor and + // report one key NAME. Names only, no values, nothing written. Payload [0x01]; a CURSOR, not an index, + // so the same frame is repeated to walk the list. Bounded by ConfigKeySweep.MAX_ENUMERATION_STEPS and + // by the strap's own end marker. Driven ONLY by WhoopBleClient.probeDeviceConfigValues(). (#103) + SEND_NEXT_DEVICE_CONFIG(116), // START_FF_KEY_EXCHANGE (117 / 0x75) — READ-ONLY: ask the strap how many feature flags its firmware // knows. The READ half of the flag surface NOOP has only ever written (SET_CONFIG/120): the protocol's // own CommandNumber table names 117/118 alongside 119/120, and only the SET pair was implemented. diff --git a/android/app/src/main/java/com/noop/protocol/FeatureFlagProbe.kt b/android/app/src/main/java/com/noop/protocol/FeatureFlagProbe.kt index 6685f90863..e28da5fb92 100644 --- a/android/app/src/main/java/com/noop/protocol/FeatureFlagProbe.kt +++ b/android/app/src/main/java/com/noop/protocol/FeatureFlagProbe.kt @@ -113,9 +113,20 @@ object FeatureFlagProbe { /** One decode outcome: exactly one of [value] / [failure] is non-null. */ data class Parsed(val value: T?, val failure: ParseFailure?) - /** Decode a `START_FF_KEY_EXCHANGE` COMMAND_RESPONSE. CRC-gated. */ - fun parseStart(frame: ByteArray, family: DeviceFamily): Parsed { - val e = extract(frame, family, START_KEY_EXCHANGE_CMD) + /** + * Decode a `START_FF_KEY_EXCHANGE` COMMAND_RESPONSE. CRC-gated. + * + * [expecting] defaults to 117 and exists so #103's sweep can reuse this decoder for the DEVICE-CONFIG + * twin `START_DEVICE_CONFIG_KEY_EXCHANGE` (115). That reuse ASSUMES the two share a record layout — an + * inference from the naming symmetry in this repo's own `CommandNumber` table, not an observation. It + * fails closed: a mismatch surfaces as TRUNCATED or as a count [StartResponse.countIsPlausible] rejects. + */ + fun parseStart( + frame: ByteArray, + family: DeviceFamily, + expecting: Int = START_KEY_EXCHANGE_CMD, + ): Parsed { + val e = extract(frame, family, expecting) e.failure?.let { return Parsed(null, it) } val r = e.value!! if (r.record.size < 3) return Parsed(null, ParseFailure.TRUNCATED) @@ -123,9 +134,17 @@ object FeatureFlagProbe { return Parsed(StartResponse(r.resultCode, r.record[0].toInt() and 0xFF, count), null) } - /** Decode a `SEND_NEXT_FF` COMMAND_RESPONSE. CRC-gated like [parseStart]. */ - fun parseNext(frame: ByteArray, family: DeviceFamily): Parsed { - val e = extract(frame, family, SEND_NEXT_FLAG_CMD) + /** + * Decode a `SEND_NEXT_FF` COMMAND_RESPONSE. CRC-gated like [parseStart]. [expecting] defaults to 118 + * and carries the same reuse contract documented on [parseStart]: #103's sweep passes 116 + * (`SEND_NEXT_DEVICE_CONFIG`) to walk the device-config namespace. + */ + fun parseNext( + frame: ByteArray, + family: DeviceFamily, + expecting: Int = SEND_NEXT_FLAG_CMD, + ): Parsed { + val e = extract(frame, family, expecting) e.failure?.let { return Parsed(null, it) } val r = e.value!! // revision + index are the minimum: the 0xFF end marker arrives with nothing after it. diff --git a/android/app/src/test/java/com/noop/protocol/ConfigKeySweepTest.kt b/android/app/src/test/java/com/noop/protocol/ConfigKeySweepTest.kt new file mode 100644 index 0000000000..2955bbf455 --- /dev/null +++ b/android/app/src/test/java/com/noop/protocol/ConfigKeySweepTest.kt @@ -0,0 +1,280 @@ +package com.noop.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * #103: byte-parity twin of the Swift `ConfigKeySweepTests` — the key-existence ORACLE, the candidate + * catalogue's derivation discipline, and the batching arithmetic that makes a catalogue larger than one + * run's budget truncate visibly instead of silently. + * + * The catalogue is data, so these tests are about its INVARIANTS — no duplicates, nothing already known, + * nothing already ruled out, nothing that cannot survive the 32-byte wire field — plus a golden string + * the Swift twin asserts in the same shape so the two lists cannot drift. + */ +class ConfigKeySweepTest { + + private val flagKeys: List get() = Whoop5Config.enableR22Sequence.map { it.name } + + // ---- The oracle ---- + + @Test + fun oracleReadsTheResultCodeAndNothingElse() { + assertEquals(ConfigKeySweep.Existence.EXISTS, ConfigKeySweep.existence(1)) // SUCCESS + assertEquals(ConfigKeySweep.Existence.UNKNOWN, ConfigKeySweep.existence(0)) // FAILURE + assertEquals(ConfigKeySweep.Existence.INCONCLUSIVE, ConfigKeySweep.existence(2)) // PENDING + assertEquals(ConfigKeySweep.Existence.INCONCLUSIVE, ConfigKeySweep.existence(3)) // UNSUPPORTED + assertEquals(ConfigKeySweep.Existence.INCONCLUSIVE, ConfigKeySweep.existence(9)) + } + + /** WHOOP 4.0 carries no labelled result code here, so the oracle must decline rather than read the + * absence as "the key does not exist". */ + @Test + fun absentResultCodeIsInconclusiveNotUnknown() { + assertEquals(ConfigKeySweep.Existence.INCONCLUSIVE, ConfigKeySweep.existence(null)) + assertNotEquals(ConfigKeySweep.Existence.UNKNOWN, ConfigKeySweep.existence(null)) + } + + @Test + fun existenceLabelsAreStableAcrossPlatforms() { + assertEquals("exists", ConfigKeySweep.Existence.EXISTS.label) + assertEquals("unknown", ConfigKeySweep.Existence.UNKNOWN.label) + assertEquals("inconclusive", ConfigKeySweep.Existence.INCONCLUSIVE.label) + } + + // ---- Enumeration verbs ---- + + @Test + fun enumerationOpcodesAreTheDeviceConfigPair() { + assertEquals(115, ConfigKeySweep.START_DEVICE_CONFIG_KEY_EXCHANGE_CMD) // 0x73 + assertEquals(116, ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD) // 0x74 + // The body is the bare inner b3 byte, exactly as the 117/118 pair sends it. + assertTrue(byteArrayOf(0x01).contentEquals(ConfigKeySweep.ENUMERATION_REQUEST_BODY)) + } + + // ---- Catalogue invariants ---- + + @Test + fun catalogueHasNoDuplicateNames() { + val keys = ConfigKeySweep.CATALOGUE.map { it.key } + assertEquals("a duplicate spends a round-trip for no information", keys.size, keys.toSet().size) + } + + /** A candidate that NOOP already writes is not a candidate — it is a known key, and asking it in the + * candidate phase would inflate an "exists" count with something the probe already knew. */ + @Test + fun catalogueNeverRepeatsAKeyNoopAlreadyWrites() { + for (c in ConfigKeySweep.CATALOGUE) { + assertFalse("${c.key} is already in enableR22Sequence", flagKeys.contains(c.key)) + assertNotEquals(DeviceConfigReadProbe.DEVICE_CONFIG_DISCOVERY_KEY, c.key) + } + } + + /** The eight plain-English oxygen names already came back FAILURE on a real strap. Re-asking them + * would spend round-trips to re-learn a known negative. */ + @Test + fun catalogueNeverRepeatsARetiredName() { + for (c in ConfigKeySweep.CATALOGUE) { + assertFalse( + "${c.key} already answered FAILURE — it belongs in RETIRED_KEYS, not the catalogue", + ConfigKeySweep.RETIRED_KEYS.contains(c.key), + ) + } + assertEquals(ConfigKeySweep.RETIRED_KEYS.size, ConfigKeySweep.RETIRED_KEYS.toSet().size) + } + + /** Names are TRUNCATED to 32 bytes on the wire, not rejected, so two candidates sharing a 32-byte + * prefix would be indistinguishable — and a longer name could never match anyway. */ + @Test + fun everyCandidateFitsTheWireNameField() { + for (c in ConfigKeySweep.CATALOGUE) { + val bytes = c.key.toByteArray(Charsets.UTF_8) + assertTrue("${c.key} is too long", bytes.size <= DeviceConfigReadProbe.NAME_FIELD_BYTES) + assertTrue(bytes.isNotEmpty()) + for (b in bytes) { + val v = b.toInt() and 0xFF + assertTrue( + "${c.key} is not lowercase snake_case, which every confirmed key is", + v in 97..122 || v in 48..57 || v == 95, + ) + } + } + } + + /** The derivation is the product: a candidate whose family is unexplained is a guess with no argument + * behind it, and a negative result on it rules nothing out. */ + @Test + fun everyDerivationIsUsedAndTitled() { + for (d in ConfigKeySweep.Derivation.entries) { + assertTrue( + "${d.name} has a title but no candidates", + ConfigKeySweep.CATALOGUE.any { it.derivation == d }, + ) + assertTrue(d.title.isNotEmpty()) + } + } + + /** Only the `whoop_…` family belongs to the device-config namespace — that prefix is the one shape a + * confirmed device-config key has. */ + @Test + fun namespaceAssignmentFollowsTheOnlyConfirmedDeviceConfigShape() { + for (c in ConfigKeySweep.CATALOGUE) { + if (c.namespace == ConfigKeySweep.Namespace.DEVICE_CONFIG) { + assertTrue("${c.key} is asked of 121 but is not whoop_-shaped", c.key.startsWith("whoop_")) + } else { + assertFalse("${c.key} is whoop_-shaped but asked of 128", c.key.startsWith("whoop_")) + } + } + } + + /** The single highest-value entry: v7 is the hole in an OBSERVED contiguous series, so a SUCCESS on it + * would prove the oracle finds keys NOOP does not already know. */ + @Test + fun theObservedSeriesHoleIsInTheCatalogue() { + val keys = ConfigKeySweep.CATALOGUE.map { it.key } + assertTrue(keys.contains("enable_r22_v7_packets")) + assertFalse(flagKeys.contains("enable_r22_v7_packets")) + assertTrue(flagKeys.contains("enable_r22_v6_packets")) + assertTrue(flagKeys.contains("enable_r22_v8_packets")) + } + + // ---- Batching ---- + + @Test + fun todaysCatalogueFitsInOneRun() { + assertTrue(ConfigKeySweep.CATALOGUE.size <= ConfigKeySweep.MAX_KEYS_PER_RUN) + val b = ConfigKeySweep.batch(0) + assertEquals(0, b.start) + assertEquals(ConfigKeySweep.CATALOGUE.size, b.candidates.size) + assertEquals(0, b.remaining) + assertTrue(b.completesCatalogue) + assertEquals("a completed catalogue restarts the next run at the top", 0, b.nextCursor) + } + + /** The property the sweep exists to guarantee: a catalogue larger than one run's budget is truncated + * VISIBLY (`remaining` is non-zero) and resumed from `nextCursor`, never silently cut. */ + @Test + fun anOversizeCatalogueTruncatesVisiblyAndResumes() { + val total = ConfigKeySweep.CATALOGUE.size + val first = ConfigKeySweep.batch(0, 10) + assertEquals(10, first.candidates.size) + assertEquals(0, first.start) + assertEquals(total - 10, first.remaining) + assertFalse(first.completesCatalogue) + assertEquals(10, first.nextCursor) + + val second = ConfigKeySweep.batch(first.nextCursor, 10) + assertEquals(10, second.start) + assertEquals(ConfigKeySweep.CATALOGUE[10].key, second.candidates.first().key) + assertEquals(total - 20, second.remaining) + + val seen = mutableListOf() + var cursor = 0 + do { + val b = ConfigKeySweep.batch(cursor, 10) + seen.addAll(b.candidates.map { it.key }) + cursor = b.nextCursor + } while (cursor != 0) + assertEquals(ConfigKeySweep.CATALOGUE.map { it.key }, seen) + } + + @Test + fun aStaleOrNonsenseCursorRestartsRatherThanWastingARun() { + assertEquals(0, ConfigKeySweep.batch(-1).start) + assertEquals(0, ConfigKeySweep.batch(10_000).start) + assertEquals(0, ConfigKeySweep.batch(ConfigKeySweep.CATALOGUE.size).start) + assertTrue(ConfigKeySweep.batch(-1).candidates.isNotEmpty()) + } + + /** A slice never wraps mid-batch, so one run can never ask the same name twice. */ + @Test + fun aSliceNeverWrapsWithinOneRun() { + val b = ConfigKeySweep.batch(ConfigKeySweep.CATALOGUE.size - 3, 10) + assertEquals(3, b.candidates.size) + assertEquals(0, b.nextCursor) + assertEquals(b.candidates.size, b.candidates.map { it.key }.toSet().size) + } + + // ---- Cross-platform lockstep ---- + + /** The catalogue is duplicated in Swift by hand, so pin it as one string the Swift twin asserts in the + * same shape. A name added on one platform and not the other fails HERE, not on a user's strap. */ + @Test + fun catalogueIsPinnedForTheSwiftTwin() { + val pinned = ConfigKeySweep.CATALOGUE.joinToString("\n") { + "${it.derivation.name}:${it.namespace.name}:${it.key}" + } + assertEquals(GOLDEN_CATALOGUE, pinned) + assertEquals(54, ConfigKeySweep.CATALOGUE.size) + assertEquals(GOLDEN_RETIRED, ConfigKeySweep.RETIRED_KEYS.joinToString("\n")) + } + + private companion object { + const val GOLDEN_CATALOGUE = """SIG_SERIES:FEATURE_FLAG:enable_sig1 +SIG_SERIES:FEATURE_FLAG:enable_sig2 +SIG_SERIES:FEATURE_FLAG:enable_sig3 +SIG_SERIES:FEATURE_FLAG:enable_sig4 +SIG_SERIES:FEATURE_FLAG:enable_sig5 +SIG_SERIES:FEATURE_FLAG:enable_sig6 +SIG_SERIES:FEATURE_FLAG:enable_sig7 +SIG_SERIES:FEATURE_FLAG:enable_sig8 +SIG_SERIES:FEATURE_FLAG:enable_sig9 +SIG_SERIES:FEATURE_FLAG:enable_sig10 +SIG_SERIES:FEATURE_FLAG:enable_sig13 +SIG_SERIES:FEATURE_FLAG:enable_sig14 +SIG_SERIES:FEATURE_FLAG:enable_sig15 +SIG_SERIES:FEATURE_FLAG:enable_sig16 +SIG_SERIES:FEATURE_FLAG:enable_sig11 +SIG_SERIES:FEATURE_FLAG:enable_sig12_during_sleep +R22_VERSION_GAPS:FEATURE_FLAG:enable_r22_v1_packets +R22_VERSION_GAPS:FEATURE_FLAG:enable_r22_v7_packets +R22_VERSION_GAPS:FEATURE_FLAG:enable_r22_v9_packets +R22_VERSION_GAPS:FEATURE_FLAG:enable_r22_v10_packets +REVISION_SLOT:FEATURE_FLAG:enable_r7_packets +REVISION_SLOT:FEATURE_FLAG:enable_r10_packets +REVISION_SLOT:FEATURE_FLAG:enable_r11_packets +REVISION_SLOT:FEATURE_FLAG:enable_r16_packets +REVISION_SLOT:FEATURE_FLAG:enable_r17_packets +REVISION_SLOT:FEATURE_FLAG:enable_r20_packets +REVISION_SLOT:FEATURE_FLAG:enable_r21_packets +REVISION_SLOT:FEATURE_FLAG:enable_pip_r26_packets +OPTICAL_AFE:FEATURE_FLAG:enable_optical_data +OPTICAL_AFE:FEATURE_FLAG:enable_optical_packets +OPTICAL_AFE:FEATURE_FLAG:make_optical_visible +OPTICAL_AFE:FEATURE_FLAG:enable_afe_packets +OPTICAL_AFE:FEATURE_FLAG:red_hw_switching +OPTICAL_AFE:FEATURE_FLAG:green_hw_switching +LABRADOR_ECG:FEATURE_FLAG:enable_labrador_packets +LABRADOR_ECG:FEATURE_FLAG:enable_labrador_raw_save +LABRADOR_ECG:FEATURE_FLAG:enable_labrador_filtered +LABRADOR_ECG:FEATURE_FLAG:make_labrador_visible +LABRADOR_ECG:FEATURE_FLAG:enable_ecg_packets +RESEARCH_HIGH_RATE:FEATURE_FLAG:enable_research_packets +RESEARCH_HIGH_RATE:FEATURE_FLAG:make_research_visible +RESEARCH_HIGH_RATE:FEATURE_FLAG:enable_raw_packets +RESEARCH_HIGH_RATE:FEATURE_FLAG:enable_hrfm_packets +SIGPROC_OXYGEN:FEATURE_FLAG:make_spo2_visible +SIGPROC_OXYGEN:FEATURE_FLAG:enable_spo2_during_sleep +SIGPROC_OXYGEN:FEATURE_FLAG:enable_spo2_gen5 +SIGPROC_OXYGEN:FEATURE_FLAG:spo2_ch_switching +SIGPROC_OXYGEN:FEATURE_FLAG:disable_spo2_packets +SIGPROC_OXYGEN:FEATURE_FLAG:enable_sigproc_spo2 +SIGPROC_OXYGEN:FEATURE_FLAG:sigproc_spo2_during_sleep +DEVICE_CONFIG_NAMESPACE:DEVICE_CONFIG:whoop_live_hrv_in_adv_ind_pkt +DEVICE_CONFIG_NAMESPACE:DEVICE_CONFIG:whoop_live_spo2_in_adv_ind_pkt +DEVICE_CONFIG_NAMESPACE:DEVICE_CONFIG:whoop_live_temp_in_adv_ind_pkt +DEVICE_CONFIG_NAMESPACE:DEVICE_CONFIG:whoop_live_ecg_in_adv_ind_pkt""" + + const val GOLDEN_RETIRED = """enable_spo2 +enable_spo2_packets +spo2_enable +enable_blood_oxygen +blood_oxygen_enable +enable_pulse_ox +enable_oxygen_packets +spo2_subscription_enabled""" + } +} diff --git a/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt b/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt index 5c328ed5a6..f5d5333b8d 100644 --- a/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt +++ b/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt @@ -65,18 +65,21 @@ class DeviceConfigReadProbeTest { private fun report( family: DeviceFamily = DeviceFamily.WHOOP5, flags: List = flagKeys, - candidates: List = DeviceConfigReadProbe.OXYGEN_CANDIDATE_KEYS, - ) = DeviceConfigReadProbeReport(family, flags, candidates) + batch: ConfigKeySweep.Batch = ConfigKeySweep.batch(0), + ) = DeviceConfigReadProbeReport(family, flags, batch) // MARK: - The read-only allowlist (the hard safety constraint) @Test - fun allowlistAdmitsOnlyTheTwoReadVerbs() { - assertEquals(121, DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD) // 0x79 - assertEquals(128, DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD) // 0x80 - assertEquals(setOf(121, 128), DeviceConfigReadProbe.READ_ONLY_OPCODES) - assertTrue(DeviceConfigReadProbe.isReadOnlyOpcode(121)) - assertTrue(DeviceConfigReadProbe.isReadOnlyOpcode(128)) + fun allowlistAdmitsOnlyTheFourReadVerbs() { + assertEquals(121, DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD) // 0x79 + assertEquals(128, DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD) // 0x80 + assertEquals(115, ConfigKeySweep.START_DEVICE_CONFIG_KEY_EXCHANGE_CMD) // 0x73 + assertEquals(116, ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD) // 0x74 + assertEquals(setOf(115, 116, 121, 128), DeviceConfigReadProbe.READ_ONLY_OPCODES) + for (op in DeviceConfigReadProbe.READ_ONLY_OPCODES) { + assertTrue(DeviceConfigReadProbe.isReadOnlyOpcode(op)) + } } /** @@ -99,18 +102,22 @@ class DeviceConfigReadProbeTest { ) } - /** Nothing outside the pair passes either — including the #761 enumerate verbs and the destructive - * opcodes that must never come near this path. */ + /** Nothing outside the four passes either — including the feature-flag enumerate verbs #872 owns + * (their own probe, their own gate) and the destructive opcodes that must never come near this path. */ @Test fun allowlistRejectsEveryOtherOpcode() { + var rejected = 0 for (op in 0..255) { if (DeviceConfigReadProbe.READ_ONLY_OPCODES.contains(op)) continue assertFalse("opcode $op must not pass", DeviceConfigReadProbe.isReadOnlyOpcode(op)) + rejected += 1 } + assertEquals("four admitted, every other opcode rejected", 252, rejected) assertFalse(DeviceConfigReadProbe.isReadOnlyOpcode(FeatureFlagProbe.START_KEY_EXCHANGE_CMD)) assertFalse(DeviceConfigReadProbe.isReadOnlyOpcode(FeatureFlagProbe.SEND_NEXT_FLAG_CMD)) assertFalse(DeviceConfigReadProbe.isReadOnlyOpcode(25)) // FORCE_TRIM assertFalse(DeviceConfigReadProbe.isReadOnlyOpcode(29)) // REBOOT_STRAP + assertFalse(DeviceConfigReadProbe.isReadOnlyOpcode(32)) // POWER_CYCLE_STRAP } // MARK: - Request body @@ -260,235 +267,401 @@ class DeviceConfigReadProbeTest { ) } - // MARK: - The plan + // ---- Enumeration frame builders (the 117/118 record layouts, reused for 115/116) ---- - @Test - fun discoveryTriesEachVerbOnceBeforeAnythingElse() { - val rep = report() - val first = rep.nextStep()!! - assertEquals(128, first.opcode) - assertEquals("enable_r22_packets", first.key) - assertEquals(DeviceConfigReadProbeReport.Group.DISCOVERY, first.group) + /** `START_DEVICE_CONFIG_KEY_EXCHANGE` reply: record = [revision][count u16 LE]. */ + private fun enumStart(result: Int, revision: Int, count: Int): ByteArray = + whoop5Response( + ConfigKeySweep.START_DEVICE_CONFIG_KEY_EXCHANGE_CMD, + payload( + result, + byteArrayOf(revision.toByte(), (count and 0xFF).toByte(), ((count shr 8) and 0xFF).toByte()), + ), + ) - val second = rep.nextStep()!! - assertEquals(121, second.opcode) - assertEquals(DeviceConfigReadProbe.DEVICE_CONFIG_DISCOVERY_KEY, second.key) - assertEquals(DeviceConfigReadProbeReport.Group.DISCOVERY, second.group) + /** `SEND_NEXT_DEVICE_CONFIG` reply: record = [revision][index][validKey][key ASCII NUL-terminated]. */ + private fun enumNext(index: Int, key: String?, validKey: Boolean = true, result: Int = 1): ByteArray { + var record = byteArrayOf(0x0A, index.toByte(), if (validKey) 1 else 0) + if (key != null) record += key.toByteArray(Charsets.UTF_8) + byteArrayOf(0) + return whoop5Response(ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD, payload(result, record)) } + private fun startReply(frame: ByteArray): FeatureFlagProbe.StartResponse = + FeatureFlagProbe.parseStart( + frame, + DeviceFamily.WHOOP5, + ConfigKeySweep.START_DEVICE_CONFIG_KEY_EXCHANGE_CMD, + ).value!! + + private fun nextReply(frame: ByteArray): FeatureFlagProbe.NextResponse = + FeatureFlagProbe.parseNext( + frame, + DeviceFamily.WHOOP5, + ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD, + ).value!! + + /** A two-flag report with a two-name candidate slice — small enough to drive step by step. */ + private fun smallReport(limit: Int = 2) = DeviceConfigReadProbeReport( + DeviceFamily.WHOOP5, + listOf("enable_r22_packets", "hr_ch_switching"), + ConfigKeySweep.batch(0, limit), + ) + + private fun valueReply(resultCode: Int?, record: ByteArray) = + DeviceConfigReadProbe.ValueResponse(resultCode, record) + + // ---- The plan: enumerate first, guess last ---- + + /** The whole point of the restructure: nothing is guessed until the strap has been asked to list its + * own keys. */ @Test - fun bothVerbsUnsupportedEndsTheProbeAfterTwoRoundTrips() { - val rep = report() - repeat(2) { - val step = rep.nextStep()!! - val frame = whoop5Response(step.opcode, payload(3, byteArrayOf(0, 0, 0))) - val r = DeviceConfigReadProbe.parse(frame, DeviceFamily.WHOOP5, step.opcode).value!! - rep.noteReply(r, step) + fun theProbeAsksTheStrapToEnumerateBeforeItGuessesAnything() { + val report = smallReport() + val first = report.nextStep()!! + assertEquals(ConfigKeySweep.START_DEVICE_CONFIG_KEY_EXCHANGE_CMD, first.opcode) + assertEquals(DeviceConfigReadProbeReport.Group.ENUMERATE, first.group) + assertNull(first.derivation) + } + + /** If the strap lists its own device-config keys there is nothing left to guess, so the sweep is + * skipped entirely rather than spending round-trips on names the answer already covers. */ + @Test + fun anAnsweringEnumerationSkipsTheGuessedSweepEntirely() { + val report = smallReport() + val s1 = report.nextStep()!! + assertEquals(115, s1.opcode) + report.noteEnumerationStart(startReply(enumStart(1, 10, 2))) + + val s2 = report.nextStep()!! + assertEquals(ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD, s2.opcode) + assertTrue(report.noteEnumerationNext(nextReply(enumNext(1, "whoop_live_hr_in_adv_ind_pkt")))) + report.nextStep()!! + assertTrue(report.noteEnumerationNext(nextReply(enumNext(2, "whoop_sleep_coach_enabled")))) + report.nextStep()!! + assertFalse(report.noteEnumerationNext(nextReply(enumNext(0xFF, null, validKey = false)))) + + assertEquals( + listOf("whoop_live_hr_in_adv_ind_pkt", "whoop_sleep_coach_enabled"), + report.enumeratedKeys, + ) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.ANSWERED, report.enumerationVerb) + + var guard = 0 + while (guard < 200) { + val step = report.nextStep() ?: break + guard += 1 + assertFalse( + "the sweep must not run once enumeration answered", + step.group == DeviceConfigReadProbeReport.Group.CANDIDATE, + ) + report.noteReply(valueReply(1, echoRecord(step.key, 0x32)), step) } - assertNull("a refused verb must not drive sixteen more round-trips", rep.nextStep()) - assertEquals(2, rep.steps) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, rep.featureFlagVerb) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, rep.deviceConfigVerb) - assertTrue(rep.verdict.contains("rejected as UNSUPPORTED")) - assertTrue(rep.render().contains("neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121)")) + assertTrue(report.render().contains("skipped — the strap enumerated its own device-config keys")) + // The Broadcast-HR key is one NOOP already writes, so only the second name is NEW. + assertEquals(listOf("whoop_sleep_coach_enabled"), report.newKeysFound) + assertTrue(report.verdict.startsWith("1 config key name(s) found that NOOP did not have")) } + /** The #874 discipline, inherited: the strap's own end marker stops the walk, but a name OUR parser + * declines is counted and stepped over — one bad entry must not throw away every key after it. */ @Test - fun silentVerbsEndTheProbeAndAreSaidPlainly() { - val rep = report() - repeat(2) { rep.noteTimeout(rep.nextStep()!!, 8) } - assertNull(rep.nextStep()) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.SILENT, rep.featureFlagVerb) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.SILENT, rep.deviceConfigVerb) - val text = rep.render() - assertTrue(text.contains("no reply to either")) - assertTrue(text.contains("no COMMAND_RESPONSE within 8s")) - assertTrue(text.contains("(none — the verb that would carry them did not answer)")) + fun anUndecodableNameIsSteppedOverRatherThanEndingTheWalk() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(1, 10, 3))) + report.nextStep() + assertTrue( + report.noteEnumerationNext( + FeatureFlagProbe.NextResponse(1, 10, 1, validKey = true, key = null), + ), + ) + report.nextStep() + assertTrue(report.noteEnumerationNext(nextReply(enumNext(2, "whoop_after_the_bad_one")))) + assertEquals(listOf("whoop_after_the_bad_one"), report.enumeratedKeys) + assertEquals(1, report.enumerationSkipped) } + /** A refused enumeration is the case the guessing fallback exists for — and it must cost exactly one + * round-trip, not one per key. */ @Test - fun anAnsweringFeatureFlagVerbWalksTheFifteenRemainingKnownFlags() { - val rep = report() - val s128 = rep.nextStep()!! - rep.noteReply( - DeviceConfigReadProbe.ValueResponse(1, echoRecord(s128.key, 0x32, byteArrayOf(0x01))), s128, + fun anUnsupportedEnumerationCostsOneRoundTripAndOpensTheFallback() { + val report = smallReport() + val s1 = report.nextStep()!! + assertEquals(115, s1.opcode) + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, report.enumerationVerb) + + val s2 = report.nextStep()!! + assertEquals( + "116 must not be asked once 115 refused", + DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD, + s2.opcode, ) - val s121 = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(3, byteArrayOf(0)), s121) + assertEquals(DeviceConfigReadProbeReport.Group.DISCOVERY, s2.group) + } - val flagSteps = mutableListOf() - val candidateSteps = mutableListOf() - while (true) { - val step = rep.nextStep() ?: break - assertEquals("the refused verb must never be sent again", 128, step.opcode) - when (step.group) { - DeviceConfigReadProbeReport.Group.KNOWN_FLAG -> flagSteps.add(step.key) - DeviceConfigReadProbeReport.Group.CANDIDATE -> candidateSteps.add(step.key) - DeviceConfigReadProbeReport.Group.DISCOVERY -> throw AssertionError("discovery is over") - } - rep.noteReply( - DeviceConfigReadProbe.ValueResponse(1, echoRecord(step.key, 0x31, byteArrayOf(0x01))), step, - ) - } - assertEquals(flagKeys.drop(1), flagSteps) - assertEquals(DeviceConfigReadProbe.OXYGEN_CANDIDATE_KEYS, candidateSteps) - assertEquals(2 + 15 + DeviceConfigReadProbe.OXYGEN_CANDIDATE_KEYS.size, rep.steps) + /** A silent enumeration retires the pair after ONE timeout rather than one per entry. */ + @Test + fun aSilentEnumerationRetiresAfterOneTimeout() { + val report = smallReport() + val s1 = report.nextStep()!! + report.noteTimeout(s1, 8) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.SILENT, report.enumerationVerb) + assertEquals(DeviceConfigReadProbeReport.Group.DISCOVERY, report.nextStep()!!.group) + assertTrue(report.render().contains("(none — no reply to 115)")) } @Test - fun candidatesPreferTheDeviceConfigVerbWhenBothAnswer() { - val rep = report(flags = listOf("only_flag"), candidates = listOf("enable_spo2")) - val a = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(1, echoRecord(a.key, 0x32)), a) - val b = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(1, echoRecord(b.key, 0x31)), b) + fun anUndecodableEnumerationReplyRetiresIt() { + val report = smallReport() + val s1 = report.nextStep()!! + report.noteFailure(DeviceConfigReadProbe.ParseFailure.CRC, s1) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNDECODABLE, report.enumerationVerb) + assertEquals(DeviceConfigReadProbeReport.Group.DISCOVERY, report.nextStep()!!.group) + assertEquals("CRC failed — frame rejected (never decoded)", report.stopReason) + } + + // ---- Cross-namespace ---- - val c = rep.nextStep()!! - assertEquals(DeviceConfigReadProbeReport.Group.CANDIDATE, c.group) - assertEquals(121, c.opcode) - assertEquals("enable_spo2", c.key) + /** Two round-trips that settle whether the namespaces are separate — the result shapes every later + * sweep, so it is asked of each verb that answered. */ + @Test + fun crossNamespaceIsAskedOfEachAnsweringVerb() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) + + val d1 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord(d1.key, 0x32)), d1) + val d2 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord(d2.key, 0x30)), d2) + + val x1 = report.nextStep()!! + assertEquals(DeviceConfigReadProbeReport.Group.CROSS_NAMESPACE, x1.group) + assertEquals(DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD, x1.opcode) + assertEquals(DeviceConfigReadProbe.DEVICE_CONFIG_DISCOVERY_KEY, x1.key) + report.noteReply(valueReply(0, ByteArray(0)), x1) + + val x2 = report.nextStep()!! + assertEquals(DeviceConfigReadProbeReport.Group.CROSS_NAMESPACE, x2.group) + assertEquals(DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD, x2.opcode) + assertEquals("enable_r22_packets", x2.key) + report.noteReply(valueReply(0, ByteArray(0)), x2) + + assertEquals(ConfigKeySweep.Existence.UNKNOWN, report.featureFlagVerbOnDeviceConfigKey) + assertEquals(ConfigKeySweep.Existence.UNKNOWN, report.deviceConfigVerbOnFlagKey) + assertTrue(report.render().contains("the namespaces are separate")) } + /** If one verb turns out to serve both namespaces, everything afterwards goes through it — halving + * the work every future sweep needs, on evidence gathered in the same run. */ @Test - fun thePlanIsCappedEvenWithAnAbsurdKeyList() { - val many = (0 until 500).map { "key_$it" } - val rep = report(family = DeviceFamily.WHOOP4, flags = many, candidates = many) - var seen = 0 + fun aVerbShownToServeBothNamespacesCarriesEverythingAfterwards() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) + val d1 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord(d1.key, 0x32)), d1) + val d2 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord(d2.key, 0x30)), d2) + val x1 = report.nextStep()!! + report.noteReply(valueReply(0, ByteArray(0)), x1) + val x2 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord(x2.key, 0x32)), x2) + assertEquals(ConfigKeySweep.Existence.EXISTS, report.deviceConfigVerbOnFlagKey) + + val k1 = report.nextStep()!! + assertEquals( + "the verb proved to serve both namespaces carries the rest of the plan", + DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD, + k1.opcode, + ) + assertTrue(report.render().contains("GET_DEVICE_CONFIG_VALUE(121) serves BOTH namespaces.")) + } + + // ---- The sweep ---- + + /** Drive the plan with enumeration refused, stopping at the FIRST candidate step and handing it back + * alongside the report (a pulled step cannot be pushed back). */ + private fun driveToCandidates(limit: Int): Pair { + val report = smallReport(limit) + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) while (true) { - val step = rep.nextStep() ?: break - seen += 1 - assertTrue("the plan must terminate", seen <= DeviceConfigReadProbe.MAX_STEPS + 1) - rep.noteReply(DeviceConfigReadProbe.ValueResponse(null, echoRecord(step.key, 0x31)), step) + val step = report.nextStep() ?: return report to null + if (step.group == DeviceConfigReadProbeReport.Group.CANDIDATE) return report to step + report.noteReply(valueReply(1, echoRecord(step.key, 0x32)), step) } - assertEquals(DeviceConfigReadProbe.MAX_STEPS, seen) - assertEquals("safety cap of 64 round-trips reached", rep.stopReason) } + /** A fully-negative sweep is a RESULT, and the verdict must say so rather than reading like a + * failure. */ @Test - fun anUndecodableReplyRetiresTheVerb() { - val rep = report(candidates = emptyList()) - val step = rep.nextStep()!! - rep.noteFailure(DeviceConfigReadProbe.ParseFailure.CRC, step) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNDECODABLE, rep.featureFlagVerb) - val next = rep.nextStep()!! - assertEquals("the undecodable verb is not retried", 121, next.opcode) - rep.noteTimeout(next, 8) - assertNull(rep.nextStep()) - assertEquals("CRC failed — frame rejected (never decoded)", rep.stopReason) + fun aFullyNegativeSweepIsACleanNegativeVerdict() { + val (report, first) = driveToCandidates(2) + var step = first + val asked = mutableListOf() + while (step != null) { + assertEquals(DeviceConfigReadProbeReport.Group.CANDIDATE, step.group) + asked.add(step.key) + report.noteReply(valueReply(0, ByteArray(0)), step) + step = report.nextStep() + } + assertEquals(listOf("enable_sig1", "enable_sig2"), asked) + assertEquals( + "asked 2 candidate key name(s); this firmware has none of them (a clean negative)", + report.verdict, + ) + assertTrue(report.newKeysFound.isEmpty()) } + /** And a hit is the headline, named in the verdict so a strap log's first line carries the finding. */ @Test - fun aVerdictAlreadyReachedIsNotRewrittenByALaterReply() { - val rep = report(flags = listOf("a", "b"), candidates = emptyList()) - val first = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(1, echoRecord("a", 0x31)), first) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.ANSWERED, rep.featureFlagVerb) - val second = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(3, byteArrayOf(0)), second) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, rep.deviceConfigVerb) - rep.noteReply(DeviceConfigReadProbe.ValueResponse(1, byteArrayOf(0)), second) - assertEquals( - "a refusal is not upgraded away", - DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, rep.deviceConfigVerb, - ) + fun aCandidateThatExistsBecomesTheHeadline() { + val (report, first) = driveToCandidates(2) + val c1 = first!! + report.noteReply(valueReply(1, echoRecord(c1.key, 0x31)), c1) + val c2 = report.nextStep()!! + report.noteReply(valueReply(0, ByteArray(0)), c2) + assertEquals(listOf("enable_sig1"), report.newKeysFound) + assertEquals("1 config key name(s) found that NOOP did not have: enable_sig1", report.verdict) + assertTrue(report.trace.any { it.contains("enable_sig1") && it.contains("exists") }) + assertFalse(report.trace.any { it.contains("enable_sig2") }) } - // MARK: - Report + /** No silent truncation: the report states how many names it asked, how many the catalogue holds, and + * how many remain untested, plus where the next run resumes. */ + @Test + fun theReportStatesTestedAndUntestedCountsAndWhereToResume() { + val (report, first) = driveToCandidates(2) + var step = first + while (step != null) { + report.noteReply(valueReply(0, ByteArray(0)), step) + step = report.nextStep() + } + val text = report.render() + val total = ConfigKeySweep.CATALOGUE.size + assertTrue(text, text.contains("(2 asked of $total in the catalogue; ${total - 2} untested)")) + assertTrue(text, text.contains("Run the probe again to continue from catalogue entry 3.")) + } + /** The default catalogue is smaller than one run's budget, so a real run reports nothing untested. */ @Test - fun candidateKeysAreLabelledAsGuesses() { - val rep = report(flags = listOf("f"), candidates = listOf("enable_spo2")) - val a = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(1, echoRecord("f", 0x32)), a) - val b = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(1, echoRecord(b.key, 0x31)), b) - val c = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(0, byteArrayOf(0)), c) - val text = rep.render() - assertTrue(text.contains("Candidate oxygen keys — GUESSES, never observed on a wire or in any table")) - assertTrue(text.contains("enable_spo2")) - assertTrue(text.contains("no value (result=FAILURE(0))")) + fun aFullRunOfTodaysCatalogueLeavesNothingUntested() { + val report = report() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) + var candidates = 0 + var steps = 0 + while (steps < DeviceConfigReadProbe.MAX_STEPS) { + val step = report.nextStep() ?: break + steps += 1 + val isCandidate = step.group == DeviceConfigReadProbeReport.Group.CANDIDATE + if (isCandidate) candidates += 1 + report.noteReply(valueReply(if (isCandidate) 0 else 1, echoRecord(step.key, 0x32)), step) + } + assertEquals(ConfigKeySweep.CATALOGUE.size, candidates) + assertNull("a full default run must not hit the safety cap", report.stopReason) + assertTrue(report.render().contains("none untested")) } + /** The safety cap still binds, whatever the plan holds. */ @Test - fun oxygenCandidateListIsShortAndOxygenNamed() { - val keys = DeviceConfigReadProbe.OXYGEN_CANDIDATE_KEYS - assertTrue(keys.isNotEmpty()) - assertTrue("keep the guess list short — it is a guess list", keys.size <= 12) - assertEquals(keys.size, keys.toSet().size) - for (k in keys) { - assertTrue( - "$k does not read as oxygen-related", - k.contains("spo2") || k.contains("oxygen") || k.contains("pulse_ox"), - ) - assertTrue( - "$k would be truncated by the 32-byte name field", - k.toByteArray(Charsets.UTF_8).size <= DeviceConfigReadProbe.NAME_FIELD_BYTES, - ) + fun thePlanIsCappedEvenWhenTheStrapEnumeratesForever() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(1, 10, 9999))) + var seen = 0 + while (seen < 500) { + val step = report.nextStep() ?: break + seen += 1 + if (step.group == DeviceConfigReadProbeReport.Group.ENUMERATE) { + report.noteEnumerationNext(nextReply(enumNext(1, "whoop_stuck"))) + } else { + report.noteReply(valueReply(0, ByteArray(0)), step) + } } + assertTrue(report.steps <= DeviceConfigReadProbe.MAX_STEPS) + assertNotNull(report.stopReason) } - /** - * GOLDEN: the exact rendered report, byte-for-byte. Its Swift twin - * (`DeviceConfigReadProbeTests.testGoldenReportIsByteIdenticalAcrossPlatforms`) asserts the SAME - * literal, so a strap log reads identically on either platform. - * - * The first record's trailing `00`, and the seven-byte UNSUPPORTED record, are the puffin envelope's - * 4-byte inner padding showing through — which is exactly why the value is read as "the byte after - * the echoed name field" and not "the last byte of the record". - */ + // ---- Report ---- + + /** Byte-for-byte golden, asserted identically by the Swift twin, so a shared strap log reads the same + * either side and a wording drift fails here rather than in a user's log. */ @Test fun goldenReportIsByteIdenticalAcrossPlatforms() { - val rep = report(flags = listOf("enable_r22_packets", "hr_ch_switching"), candidates = listOf("enable_spo2")) + val report = smallReport() + val s1 = report.nextStep()!! + assertEquals(115, s1.opcode) + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) + + val s2 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord("enable_r22_packets", 0x32)), s2) + val s3 = report.nextStep()!! + report.noteReply( + valueReply(1, echoRecord(DeviceConfigReadProbe.DEVICE_CONFIG_DISCOVERY_KEY, 0x30)), + s3, + ) + val s4 = report.nextStep()!! + report.noteReply(valueReply(0, byteArrayOf(0x01, 0x00)), s4) + val s5 = report.nextStep()!! + report.noteReply(valueReply(0, byteArrayOf(0x01, 0x00)), s5) + val s6 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord("hr_ch_switching", 0x32)), s6) + val c1 = report.nextStep()!! + report.noteReply(valueReply(0, byteArrayOf(0x01, 0x00)), c1) + val c2 = report.nextStep()!! + report.noteReply(valueReply(0, byteArrayOf(0x01, 0x00)), c2) + assertNull(report.nextStep()) + + assertEquals(GOLDEN_REPORT, report.render()) + } - val s1 = rep.nextStep()!! - val f1 = whoop5Response(128, payload(1, echoRecord("enable_r22_packets", 0x32, byteArrayOf(0x01)))) - rep.noteReply(DeviceConfigReadProbe.parse(f1, DeviceFamily.WHOOP5, 128).value!!, s1) + private companion object { + const val GOLDEN_REPORT = """#103 CONFIG KEY PROBE — WHOOP 5/MG +Read-only: START_DEVICE_CONFIG_KEY_EXCHANGE(115), SEND_NEXT_DEVICE_CONFIG(116), GET_DEVICE_CONFIG_VALUE(121), GET_FF_VALUE(128). +No value is written; SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path. +Oracle: result=SUCCESS(1) means the key NAME exists; result=FAILURE(0) means the firmware has no such key. - val s2 = rep.nextStep()!! - val f2 = whoop5Response(121, payload(3, byteArrayOf(0, 0, 0, 0))) - rep.noteReply(DeviceConfigReadProbe.parse(f2, DeviceFamily.WHOOP5, 121).value!!, s2) +Verdict: asked 2 candidate key name(s); this firmware has none of them (a clean negative) - val s3 = rep.nextStep()!! - rep.noteReply( - DeviceConfigReadProbe.ValueResponse(1, echoRecord("hr_ch_switching", 0x32, byteArrayOf(0x01))), s3, - ) +Verbs: + device-config enumerate(115/116) unsupported + GET_FF_VALUE(128) answered + GET_DEVICE_CONFIG_VALUE(121) answered + +Device-config keys the strap listed for itself (115/116) (0): + (none — the firmware refused 115 as UNSUPPORTED) + +Namespace separation: + 128 asked for a device-config key unknown + 121 asked for a feature-flag key unknown + ⇒ the namespaces are separate: neither verb sees the other's keys. + +Discovery — one round-trip per value verb against a key it should know (2): + 1. enable_r22_packets = '2' (0x32) + 2. whoop_live_hr_in_adv_ind_pkt = '0' (0x30) + +Known key values (the flags NOOP writes, plus anything enumeration returned) (1): + 1. hr_ch_switching = '2' (0x32) + +Candidate key names — GUESSES, never observed on a wire or in any table (2 asked of 54 in the catalogue; 52 untested): + 0 exist · 2 do not · 0 inconclusive + + sig series (T8) — the firmware numbers its signal chains; sig11/sig12 are the two we have (2): + 1. enable_sig1 unknown + 2. enable_sig2 unknown + + Run the probe again to continue from catalogue entry 3. - val s4 = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(0, byteArrayOf(0x01, 0x00)), s4) - assertNull(rep.nextStep()) - - val golden = "#103 DEVICE-CONFIG READ PROBE — WHOOP 5/MG\n" + - "Read-only: GET_DEVICE_CONFIG_VALUE(121) + GET_FF_VALUE(128). No value is written; " + - "SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path.\n" + - "Follow-up to the #761 enumeration probe: that one asked for key NAMES, this asks for VALUES.\n" + - "\n" + - "Verdict: 1 of 2 read verbs answered; read 2 config value(s)\n" + - "\n" + - "Read verbs:\n" + - " GET_FF_VALUE(128) answered\n" + - " GET_DEVICE_CONFIG_VALUE(121) unsupported\n" + - "\n" + - "Discovery — one round-trip per verb against a key it should know (2):\n" + - " 1. enable_r22_packets = '2' (0x32)\n" + - " 2. whoop_live_hr_in_adv_ind_pkt — no value (result=UNSUPPORTED(3))\n" + - "\n" + - "Known feature-flag values (names NOOP already writes; values never read before) (1):\n" + - " 1. hr_ch_switching = '2' (0x32)\n" + - "\n" + - "Candidate oxygen keys — GUESSES, never observed on a wire or in any table (1):\n" + - " 1. enable_spo2 — no value (result=FAILURE(0))\n" + - "\n" + - "Exchange:\n" + - " GET_FF_VALUE(128) key=\"enable_r22_packets\" → result=SUCCESS(1) value='2' (0x32) " + - "record=[01 65 6e 61 62 6c 65 5f 72 32 32 5f 70 61 63 6b 65 74 73 00 00 00 00 00 00 00 00 00 " + - "00 00 00 00 00 32 00]\n" + - " GET_DEVICE_CONFIG_VALUE(121) key=\"whoop_live_hr_in_adv_ind_pkt\" → " + - "result=UNSUPPORTED(3) record=[00 00 00 00 00 00 00]\n" + - " GET_FF_VALUE(128) key=\"hr_ch_switching\" → result=SUCCESS(1) value='2' (0x32) " + - "record=[01 68 72 5f 63 68 5f 73 77 69 74 63 68 69 6e 67 00 00 00 00 00 00 00 00 00 00 00 00 " + - "00 00 00 00 00 32]\n" + - " GET_FF_VALUE(128) key=\"enable_spo2\" → result=FAILURE(0) record=[01 00]\n" - assertEquals(golden, rep.render()) +Exchange: + START_DEVICE_CONFIG_KEY_EXCHANGE(115) → result=UNSUPPORTED(3) — the firmware does not serve this verb + GET_FF_VALUE(128) key="enable_r22_packets" → result=SUCCESS(1) exists value='2' (0x32) record=[65 6e 61 62 6c 65 5f 72 32 32 5f 70 61 63 6b 65 74 73 00 00 00 00 00 00 00 00 00 00 00 00 00 00 32] + GET_DEVICE_CONFIG_VALUE(121) key="whoop_live_hr_in_adv_ind_pkt" → result=SUCCESS(1) exists value='0' (0x30) record=[77 68 6f 6f 70 5f 6c 69 76 65 5f 68 72 5f 69 6e 5f 61 64 76 5f 69 6e 64 5f 70 6b 74 00 00 00 00 30] + GET_FF_VALUE(128) key="whoop_live_hr_in_adv_ind_pkt" → result=FAILURE(0) unknown record=[01 00] + GET_DEVICE_CONFIG_VALUE(121) key="enable_r22_packets" → result=FAILURE(0) unknown record=[01 00] + GET_FF_VALUE(128) key="hr_ch_switching" → result=SUCCESS(1) exists value='2' (0x32) record=[68 72 5f 63 68 5f 73 77 69 74 63 68 69 6e 67 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 32] +""" } } diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 34c6bac602..744322f923 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -528,41 +528,70 @@ sixteen R22 feature flags in `Whoop5Config.enableR22Sequence`, `SET_DEVICE_CONFI Broadcast-HR key, #181) and has never read either. The `CommandNumber` table names the read side of both: 121 `GET_DEVICE_CONFIG_VALUE` and 128 `GET_FF_VALUE`. -**Both opcodes may simply not be implemented.** A number in the table is not a served verb — opcode 96 -(`ENTER_HIGH_FREQ_HISTORICAL_MODE`) is the standing example of one nothing in the wild sends. So the -probe's primary deliverable is a per-verb verdict — **answered**, **rejected as UNSUPPORTED**, or -**silent** — and a clean "neither verb is served" is a useful result, not a failure. It spends exactly one -round-trip per verb establishing that (128 against a flag NOOP writes, 121 against the known-good -Broadcast-HR key) before doing anything else; a verb that is refused, silent or undecodable is **retired**, -so a dead verb costs one 8 s window rather than one per key. - -Only a verb that answers goes on to read values: the sixteen known flag names (whose values NOOP has only -ever written, never read), then a short list of **guessed** oxygen-related key names against the -device-config namespace — `DeviceConfigReadProbe.oxygenCandidateKeys`, the one constant to extend, and -labelled as guesses everywhere they surface. That list is the #103 question in probe form: the byte at -deep-record offset 82 reads as real SpO2 on some straps and flat `0x00` on others, which is what a -subscription gate would look like, and a config key governing it would sit in the device-config namespace. - -Request body is `[0x01]` (the inner b3 byte) + the key as ASCII NUL-padded to 32 bytes — the SET side's own -name field minus its value byte. That shape is **inferred from the SET side, not observed**; if it is wrong -the strap answers FAILURE or nothing, which the report says plainly. The reply is an ordinary -COMMAND_RESPONSE whose record sits behind the 2-byte response header, and **beyond that offset no field -layout is assumed**: the record is reported as raw hex. A value is only ever *claimed* when the reply -echoes the requested key inside a 32-byte NUL-padded field, in which case the byte immediately after that -field is the value — the SET layout, checked rather than assumed. (On 5/MG the puffin envelope pads the -inner payload to a 4-byte boundary, so trailing NULs in a record are envelope padding; reading "the byte -after the echoed field" rather than "the last byte" is what keeps that out of the answer.) - -Read-only by construction. `DeviceConfigReadProbe.readOnlyOpcodes` is `{121, 128}` and -`isReadOnlyOpcode` is the *same predicate* the 5/MG `send()` allowlist consults — admitting them only -while a probe is in flight — so the "119/120 are never sent from this path" claim is a unit-tested property -of the allowlist rather than a comment. The plan is capped at 64 round-trips. Driven by -`BLEManager.probeDeviceConfigValues()` / `WhoopBleClient.probeDeviceConfigValues()` (user-triggered, Test -Centre → Connection, both families); parsed + planned + rendered by the pure `DeviceConfigReadProbe` / -`DeviceConfigReadProbeReport` twins (Swift↔Kotlin byte-parity, unit-tested on synthetic frames). Result -goes to a copyable dialog + the strap log; no storage. The opcode numbers come from this repo's own -protocol table (`Resources/whoop_protocol.json`). **Unverified on any strap:** nothing in this project has -ever had 121 or 128 answered. +**Confirmed on a WHOOP 5 MG (WS50_r03): both verbs answer** — and the reply is an **existence oracle**. +A key name the firmware knows answers `result = SUCCESS(1)` and carries a value byte; a name it does not +know answers `result = FAILURE(0)`. One round-trip therefore settles whether a config key NAME exists, +read-only, which turns "does a config key gate SpO2?" from an unanswerable question into a finite search. +`ConfigKeySweep.Existence` is that mapping, and it reads the RESULT CODE only: `UNSUPPORTED(3)`, any other +code, and WHOOP 4.0's unlabelled result byte are all `inconclusive`, never folded into either answer. + +**Config key probe (#103, read-only) — enumerate first, guess last.** The probe's plan is built around one +principle: ask the strap before guessing. The `CommandNumber` table names a **device-config enumeration +pair** nothing here had ever sent — 115 `START_DEVICE_CONFIG_KEY_EXCHANGE` and 116 +`SEND_NEXT_DEVICE_CONFIG` — the structural twin of the 117/118 feature-flag pair #872 built and a strap +answered. If 115/116 answer, the strap hands over its own device-config key list and no name needs +guessing at all: the candidate sweep is **skipped** and the report says so. A clean "115/116 are not +served" is equally useful and publishable — it is what promotes the guessing fallback from a shortcut to +the only available method. + +115/116 are decoded by `FeatureFlagProbe.parseStart` / `parseNext` with the opcode passed in, i.e. on the +**assumed-symmetric** 117/118 record layout. That is an inference from the naming symmetry in this repo's +own table, not an observation, and it fails closed: a layout mismatch surfaces as a short record or an +implausible count and retires the walk with a named reason rather than inventing key names. The walk +inherits #874's discipline — the strap's own end marker terminates it, but an entry whose NAME does not +decode is counted and stepped over rather than treated as the end. + +The rest of the plan, in order: two **discovery** reads (128 against a flag NOOP writes, 121 against the +Broadcast-HR key, so a FAILURE is evidence about the *verb*, not the key); two **cross-namespace** reads +that ask each verb for the OTHER namespace's known-good key and settle in two round-trips whether the +namespaces are really separate — if one verb turns out to serve both, everything afterwards goes through +it; the **values** of keys already known to exist (the sixteen flags, plus anything enumeration returned); +and finally, only when enumeration produced nothing, the **candidate sweep**. A verb that is refused, +silent or undecodable is **retired**, so a dead verb costs one 8 s window rather than one per key. + +**The candidate catalogue is derived, not free-associated.** `ConfigKeySweep.catalogue` is the one place to +extend, and every entry is a cross-product of two things already in this repo: (A) the *morphology* of the +seventeen confirmed key names — `enable__packets`, `enable__v_packets`, +`make__visible`, `__switching`, `enable_sig[_during_sleep]`, +`whoop__in_`, and four more; and (B) the *vocabulary* the firmware uses about itself — +the `CommandNumber` table's `optical` (107/108), `labrador` (124/125/139), `research` (131/132), `afe` +(61/62) and its revision tokens `r7` (16), `r10`/`r11` (63), `r20`/`r21` (153/154), plus the strap's own +console-log subsystem tags, of which `SIGPROC: generated a valid SPO2 during sleep` is the one directly on +point. That line pairs the firmware's SpO2 computation with the `SIGPROC` tag and with the same +`during sleep` phrasing the confirmed key `enable_sig11_during_sleep` uses, which is why the `sig` +number line is the catalogue's largest family. + +They remain **guesses** and are labelled as such everywhere. `ConfigKeySweep.retiredKeys` holds the eight +plain-English oxygen names a real MG already answered FAILURE — kept out of the sweep so nobody spends +round-trips re-asking, and kept in the file so nobody proposes them again. That all eight are product +English, and all eight wrong, is the argument for deriving names from the firmware's own words instead. + +One run is bounded: `ConfigKeySweep.maxKeysPerRun` names per run, resumed from a cursor and **reported** +("N asked of M in the catalogue; K untested … run again to continue from entry X"), so a catalogue grown +past the budget truncates visibly instead of silently. Today's catalogue is smaller than one run's budget, +so a run asks all of it. + +Read-only by construction. `DeviceConfigReadProbe.readOnlyOpcodes` is `{115, 116, 121, 128}` and +`isReadOnlyOpcode` is the *same predicate* the 5/MG `send()` allowlist consults — admitting them only while +a probe is in flight — so the "119/120 are never sent from this path" claim is a unit-tested property of +the allowlist rather than a comment (the tests assert 119, 120 and all 252 other opcodes are rejected). The +plan is capped at 128 round-trips. Driven by `BLEManager.probeDeviceConfigValues()` / +`WhoopBleClient.probeDeviceConfigValues()` (user-triggered, Test Centre → Connection, both families); +parsed + planned + rendered by the pure `ConfigKeySweep` / `DeviceConfigReadProbe` / +`DeviceConfigReadProbeReport` twins (Swift↔Kotlin byte-parity, unit-tested on synthetic frames, with a +golden report asserted byte-for-byte both sides). Result goes to a copyable dialog + the strap log; no +storage. The opcode numbers come from this repo's own protocol table (`Resources/whoop_protocol.json`). +**Unverified on any strap:** nothing in this project has ever had 115 or 116 answered. **GET_DATA_RANGE ring backlog (#689, diagnostic only).** Beyond the oldest/newest timestamps NOOP already scans from a `GET_DATA_RANGE` reply, the app computes a ring-buffer page backlog from three u32s in the From c3433865d74d82b64ab9eb47c7027512c7321477 Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:50:45 -0400 Subject: [PATCH 2/2] Make the ECG gate hypothesis testable: an opt-in enable_raw_data_w_ecg write with a mandatory read-back (#891, #103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commands answer SUCCESS and produce zero ECG packets in a 30-second listen. This adds the one experiment that can move it — and, deliberately, nothing else. The key was not guessed. #898's read-only 115/116 enumeration asked the strap to list its own device-config keys and it answered revision=1 count=7, walking to a clean terminator: sigproc_wear_detect enable_rfid max_collection_backlog cont_collection_mode whoop_live_hr_in_adv_ind_pkt whoop_live_2_hrm_devices enable_raw_data_w_ecg Six of those seven were unknown here. On that strap enable_raw_data_w_ecg reads '0'. A key pairing "raw data" with "ecg", sitting at '0' on hardware whose ECG commands accept and emit nothing, is the leading candidate for the gate. WHETHER FLIPPING IT PRODUCES ECG DATA IS UNKNOWN. This is not what the official client writes, and no claim is made that it replicates any other client's behaviour. A confirmed '1' with still no packets removes the leading hypothesis from #891 and is exactly as publishable as a positive. Config writes on this firmware are demonstrated rather than assumed: running the existing enable_r22_* sequence (#174) moved enable_sig12 from '2' to '1', confirmed by a 121 read either side. The read-back is the proof, never the ack. The write's own COMMAND_RESPONSE is recorded and not believed — a SUCCESS result byte is not evidence that state changed. Every write is followed by GET_DEVICE_CONFIG_VALUE(121) on the same key and only the value that comes back is reported; a SUCCESS ack whose value did not move is reported as "unchanged", not as success. (Read-back framing owed to @ryanbr on #891.) The allowlist is now key-aware, which is a tightening. Opcode 119 is shared with the Broadcast-HR flag (#181), so an opcode-only clause cannot say "this key and no other" — and the clause this replaces admitted ANY device-config key whenever the Broadcast-HR opt-in happened to be on. DeviceConfigWriteGate.admitsSend parses the key name out of the body and admits exactly two keys, each only under its own opt-in, with enable_raw_data_w_ecg additionally requiring the strap to have attested itself an MG over DIS (Whoop5Variant.isMG; unknown is not MG, and a plain 5.0 has no electrodes). The other five enumerated keys are refused unconditionally and SET_FF_VALUE(120) is refused outright. It is one pure predicate that the send path itself consults, the same discipline DeviceConfigReadProbe.isReadOnlyOpcode established, so the tests that prove what it rejects are proving it about the real wire path: 119-with-this-key is the only admitted pair out of all 256 opcodes, under every combination of the two opt-ins. Reversible in one tap: the two directions differ only in the value byte. Also fixed, because it decides whether a negative sweep means anything: - The candidate sweep routed each name to ONE verb by an author's guess at its namespace. The namespaces are proven separate on hardware (128 asked for a device-config key answers FAILURE, and 121 asked for a feature-flag key does too), so a name asked through the wrong verb answers FAILURE and is indistinguishable from "no such key". Every candidate now goes through every answering verb, the report shows which verb said what per name, a name counts as absent only when all verbs agree, and the arithmetic stays in names rather than round-trips. The step cap moves 128 -> 320 so a doubled sweep is not silently truncated. - The sweep can now be run even when enumeration succeeds, as an explicit second menu action. Enumeration lists what the firmware HOLDS, which is not everything it would ACCEPT, and it says nothing at all about the feature-flag namespace where most of the catalogue is aimed. Off by default: it costs one round-trip per name per verb. - Device-config values are not all one byte. max_collection_backlog reads "0.0", which the single-byte reader reported as '0'. ValueResponse.stringValue reads the whole NUL-terminated ASCII value, and the read-back comparison uses it. Swift and Kotlin twins throughout; new UI copy ships with its translations in every locale the i18n gate tracks. --- .../WhoopProtocol/DeviceConfigReadProbe.swift | 164 ++++++-- .../WhoopProtocol/DeviceConfigWriteGate.swift | 351 ++++++++++++++++ .../DeviceConfigReadProbeTests.swift | 128 +++++- .../DeviceConfigWriteGateTests.swift | 339 ++++++++++++++++ Strand/App/AppModel.swift | 4 +- Strand/BLE/BLEManager.swift | 189 ++++++++- Strand/BLE/LiveState.swift | 12 + Strand/BLE/PuffinExperiment.swift | 20 + Strand/Resources/Localizable.xcstrings | 312 ++++++++++++++ Strand/Screens/DevicesView.swift | 8 + Strand/Screens/SettingsView.swift | 105 +++++ .../java/com/noop/ble/PuffinExperiment.kt | 24 +- .../main/java/com/noop/ble/WhoopBleClient.kt | 232 ++++++++++- .../noop/protocol/DeviceConfigReadProbe.kt | 172 ++++++-- .../noop/protocol/DeviceConfigWriteGate.kt | 379 ++++++++++++++++++ .../main/java/com/noop/ui/SettingsScreen.kt | 83 ++++ .../app/src/main/res/values-de/strings.xml | 5 + .../app/src/main/res/values-es/strings.xml | 5 + .../app/src/main/res/values-fr/strings.xml | 5 + .../src/main/res/values-pt-rPT/strings.xml | 5 + .../app/src/main/res/values-zh/strings.xml | 5 + android/app/src/main/res/values/strings.xml | 5 + .../protocol/DeviceConfigReadProbeTest.kt | 86 +++- .../protocol/DeviceConfigWriteGateTest.kt | 338 ++++++++++++++++ 24 files changed, 2887 insertions(+), 89 deletions(-) create mode 100644 Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigWriteGate.swift create mode 100644 Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigWriteGateTests.swift create mode 100644 android/app/src/main/java/com/noop/protocol/DeviceConfigWriteGate.kt create mode 100644 android/app/src/test/java/com/noop/protocol/DeviceConfigWriteGateTest.kt diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift index a7d40aadbe..8522ed66b7 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift @@ -118,7 +118,12 @@ public enum DeviceConfigReadProbe { /// `ConfigKeySweep.maxKeysPerRun` candidate names (when it did not) — never both, because guessing is /// pointless once the strap has handed over its own list. Worst case is 101 round-trips, comfortably /// under this; a plan that somehow exceeds it stops with a named reason rather than truncating silently. - public static let maxSteps = 128 + /// Hard ceiling on round-trips in one probe. Raised from 128 when the sweep began asking every + /// candidate through EVERY answering verb: the worst case is enumeration + 2 discovery + 2 cross + + /// the known-key reads + 2 × the catalogue, which passes 128 and would otherwise have been silently + /// truncated by the cap — reported as "safety cap reached", but still a short sweep presented as a + /// finished one. + public static let maxSteps = 320 /// The one device-config key NOOP already knows a real strap accepts: the Broadcast-HR flag written /// via `SET_DEVICE_CONFIG_VALUE` and hardware-validated in #181. Used as the discovery key for opcode @@ -206,12 +211,44 @@ public enum DeviceConfigReadProbe { /// The value byte, reported ONLY when the strap echoed `key` in a 32-byte NUL-padded name field /// and the record extends one byte past it — the same `[name 32][value]` layout the SET bodies /// use. nil means "no value claimed", never "value is zero". + /// + /// **First byte only.** Values are not all one byte (see `stringValue(for:)`); this stays as it is + /// because every caller that reports a single flag character wants exactly this, and widening it + /// would silently change what the sweep prints. public func value(for key: String) -> UInt8? { guard let off = echoOffset(of: key) else { return nil } let valueIndex = off + DeviceConfigReadProbe.nameFieldBytes guard valueIndex < record.count else { return nil } return record[valueIndex] } + + /// The value as the strap actually stores it — the WHOLE NUL-terminated ASCII string after the + /// echoed name field, not just its first byte. + /// + /// Device-config values are **not** all single characters. A WHOOP 5 MG's own 115/116 enumeration + /// listed `max_collection_backlog`, whose value reads `"0.0"` — three characters. `value(for:)` + /// would report that as `'0'` and quietly lose the rest, which is fine for a flag and wrong for + /// anything else, so any caller comparing a value against what it asked for must use this. + /// + /// Stops at the first NUL, which is what keeps the puffin envelope's 4-byte-boundary padding out + /// of the answer (the same reasoning `value(for:)` relies on for reading the byte after the name + /// rather than the last byte of the record). Returns nil — "no value claimed", never "empty" — when + /// the key was not echoed, when nothing follows the name field, or when what follows is not + /// printable ASCII, since a non-ASCII run is not a value this layout can honestly claim to have read. + public func stringValue(for key: String) -> String? { + guard let off = echoOffset(of: key) else { return nil } + let start = off + DeviceConfigReadProbe.nameFieldBytes + guard start < record.count else { return nil } + var bytes: [UInt8] = [] + for i in start.. = [] - public init(family: DeviceFamily, knownFlagKeys: [String], batch: ConfigKeySweep.Batch) { + public init(family: DeviceFamily, knownFlagKeys: [String], batch: ConfigKeySweep.Batch, + forceCandidateSweep: Bool = false) { self.family = family self.knownFlagKeys = knownFlagKeys self.batch = batch + self.forceCandidateSweep = forceCandidateSweep } + /// Whether this run will ask the guessed names at all. + /// + /// By default the sweep is a FALLBACK: a strap that enumerated its own device-config keys has already + /// answered the question guessing was for, so the sweep is skipped. + /// + /// That default is right for the device-config namespace and **incomplete as a general claim**, which + /// is why `forceCandidateSweep` exists. Enumeration reports the keys the firmware holds; a key it + /// would accept but has never been given a value for need not be among them, and the oracle cannot + /// separate that case from "no such key" because both answer `FAILURE(0)`. A successful enumeration is + /// therefore evidence about what the strap HAS, not proof of what it would ACCEPT — and it says + /// nothing at all about the FEATURE-FLAG namespace, which is where most of the catalogue is aimed. + /// + /// So the forced run stays available, and stays explicit: it costs one round-trip per catalogue name, + /// which is not something to spend by default. + public var runsCandidateSweep: Bool { forceCandidateSweep || enumeratedKeys.isEmpty } + // MARK: - Plan /// The next round-trip to send, or nil when the probe is done. Called after the previous reply has @@ -501,17 +558,39 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { return Step(opcode: verb, key: entry.key, group: .knownKey) case 4: // Guessing is the FALLBACK. If the strap enumerated its own device-config keys there is - // nothing to guess at in that namespace, so the sweep is skipped and said so in the report. - guard enumeratedKeys.isEmpty, cursor < batch.candidates.count else { return nil } - let candidate = batch.candidates[cursor] - guard let verb = verb(for: candidate.namespace) else { return nil } - return Step(opcode: verb, key: candidate.key, group: .candidate, + // nothing to guess at in that namespace, so the sweep is skipped and said so in the report — + // unless this run was explicitly asked to sweep anyway (see `runsCandidateSweep` for why a + // successful enumeration does not close the question). + // + // EVERY candidate goes through EVERY answering verb, not through the one its `namespace` + // field guesses. That field is an author's expectation, and the namespaces are now PROVEN + // SEPARATE on hardware: 128 asked for a device-config key answers FAILURE, and 121 asked for a + // feature-flag key answers FAILURE. So a candidate that really is a device-config key, asked + // only through 128, comes back FAILURE and is indistinguishable from "no such key" — which + // would have made a negative sweep worthless for exactly the names it most needed to settle. + // Asking both costs one extra round-trip per name and is what lets a negative be called clean. + guard runsCandidateSweep else { return nil } + let verbs = candidateVerbs + guard !verbs.isEmpty else { return nil } + let idx = cursor / verbs.count + guard idx < batch.candidates.count else { return nil } + let candidate = batch.candidates[idx] + return Step(opcode: verbs[cursor % verbs.count], key: candidate.key, group: .candidate, derivation: candidate.derivation) default: return nil } } + /// The VALUE verbs a candidate name is asked through — every one that answered, in a stable order + /// (121 before 128) so the plan is deterministic. Empty when neither answered, which retires the sweep. + private var candidateVerbs: [UInt8] { + var verbs: [UInt8] = [] + if deviceConfigVerb == .answered { verbs.append(DeviceConfigReadProbe.getDeviceConfigValueCmd) } + if featureFlagVerb == .answered { verbs.append(DeviceConfigReadProbe.getFeatureFlagValueCmd) } + return verbs + } + /// The keys whose values are worth reading because they are already known to exist: the sixteen flags /// NOOP writes, then whatever the strap enumerated for itself (capped, and never re-listing a flag). private var knownKeyPlan: [(key: String, namespace: ConfigKeySweep.Namespace)] { @@ -678,7 +757,9 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { if !newKeysFound.isEmpty { return "\(newKeysFound.count) config key name(s) found that NOOP did not have: \(newKeysFound.joined(separator: ", "))" } - if enumerationVerb == .answered { + // Only claim "enumeration settled it" when enumeration was in fact the whole run. A forced sweep + // asked dozens of names as well, and its clean negative is the more informative headline. + if enumerationVerb == .answered, candidateReadings.isEmpty { return "the strap enumerated its device-config namespace and returned no key NOOP did not already have" } let answered = [featureFlagVerb, deviceConfigVerb].filter { $0 == .answered }.count @@ -692,13 +773,26 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { } return both } - let asked = candidateReadings.count + // Counted in NAMES, not round-trips: each name is asked through every answering verb, so the + // headline would otherwise double. A name only counts as "does not exist" when EVERY verb that + // asked it said so. + var names: [String] = [] + for r in candidateReadings where !names.contains(r.key) { names.append(r.key) } + let asked = names.count if asked == 0 { return "\(answered) of 2 read verbs answered; no candidate name was asked" } - let unknown = candidateReadings.filter { $0.existence == .unknown }.count + let unknown = names.filter { key in + let rows = candidateReadings.filter { $0.key == key } + return !rows.isEmpty && rows.allSatisfy { $0.existence == .unknown } + }.count if unknown == asked { - return "asked \(asked) candidate key name(s); this firmware has none of them (a clean negative)" + // A fully-negative sweep is worth more when enumeration ALSO answered: the device-config + // namespace is then fully listed and the guessed names are all refused, which is a much + // stronger negative than a sweep run against a strap that never listed anything. + return enumerationVerb == .answered + ? "asked \(asked) candidate key name(s); this firmware has none of them, and its device-config namespace enumerated in full (a clean negative)" + : "asked \(asked) candidate key name(s); this firmware has none of them (a clean negative)" } return "asked \(asked) candidate key name(s); \(unknown) do not exist, \(asked - unknown) inconclusive" } @@ -789,16 +883,26 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// The candidate sweep, grouped by derivation, with the tested/untested arithmetic spelled out. private func candidateSection() -> String { let rows = candidateReadings - let tested = rows.count + // NAMES, not round-trips: each name is now asked through every answering verb, so counting rows + // would report "108 asked of 54 in the catalogue". The arithmetic has to stay in the same units + // the catalogue is measured in or the untested figure goes negative and stops meaning anything. + var seen: [String] = [] + for r in rows where !seen.contains(r.key) { seen.append(r.key) } + let tested = seen.count let total = ConfigKeySweep.catalogue.count let untested = total - batch.start - tested var sb = "\nCandidate key names — GUESSES, never observed on a wire or in any table" + if forceCandidateSweep { + sb += " [FULL SWEEP: asked even though enumeration succeeded]" + } sb += " (\(tested) asked of \(total) in the catalogue" sb += untested > 0 ? "; \(untested) untested" : "; none untested" sb += "):\n" if rows.isEmpty { - if !enumeratedKeys.isEmpty { - sb += " (skipped — the strap enumerated its own device-config keys, so nothing needs guessing)\n" + if !enumeratedKeys.isEmpty && !forceCandidateSweep { + sb += " (skipped — the strap enumerated its own device-config keys, so nothing needs guessing.\n" + sb += " Enumeration lists what the firmware HOLDS, not everything it would ACCEPT, and says\n" + sb += " nothing about the feature-flag namespace: re-run with the full name sweep to ask anyway.)\n" } else if featureFlagVerb != .answered && deviceConfigVerb != .answered { sb += " (none — no value verb answered, so no name could be asked)\n" } else { @@ -806,17 +910,29 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { } return sb } - let exists = rows.filter { $0.existence == .exists }.count - let unknown = rows.filter { $0.existence == .unknown }.count - sb += " \(exists) exist · \(unknown) do not · \(tested - exists - unknown) inconclusive\n" + // A NAME exists if ANY verb said so, and is only "does not exist" when EVERY verb that asked said + // so — the whole reason both verbs are asked. + let exists = seen.filter { key in rows.contains { $0.key == key && $0.existence == .exists } }.count + let unknown = seen.filter { key in + let asked = rows.filter { $0.key == key } + return !asked.isEmpty && asked.allSatisfy { $0.existence == .unknown } + }.count + sb += " \(exists) exist · \(unknown) do not · \(tested - exists - unknown) inconclusive" + sb += " (each name asked through \(candidateVerbs.count) verb(s))\n" for derivation in ConfigKeySweep.Derivation.allCases { - let group = rows.filter { $0.derivation == derivation } - guard !group.isEmpty else { continue } - sb += "\n \(derivation.title) (\(group.count)):\n" - for (i, r) in group.enumerated() { - sb += String(format: " %2d. ", i + 1) + DeviceConfigReadProbe.padded(r.key, to: 32) - + r.existence.label - if let v = r.value { sb += " = " + DeviceConfigReadProbe.valueLabel(v) } + let names = seen.filter { key in rows.contains { $0.key == key && $0.derivation == derivation } } + guard !names.isEmpty else { continue } + sb += "\n \(derivation.title) (\(names.count)):\n" + for (i, key) in names.enumerated() { + sb += String(format: " %2d. ", i + 1) + DeviceConfigReadProbe.padded(key, to: 32) + // Per-verb, so a name that answered differently on 121 and 128 is visible rather than + // collapsed into one word. + let asked = rows.filter { $0.key == key } + sb += asked.map { r in + var cell = "\(r.opcode)=\(r.existence.label)" + if let v = r.value { cell += "(" + DeviceConfigReadProbe.valueLabel(v) + ")" } + return cell + }.joined(separator: " · ") sb += "\n" } } diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigWriteGate.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigWriteGate.swift new file mode 100644 index 0000000000..634ccd1434 --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigWriteGate.swift @@ -0,0 +1,351 @@ +import Foundation + +/// #891 / #103: the ONE device-config key this app may write beyond the Broadcast-HR flag — +/// `enable_raw_data_w_ecg` — and the key-aware allowlist that keeps every other key off the wire. +/// +/// ## Where the key came from +/// +/// The strap listed it itself. `START_DEVICE_CONFIG_KEY_EXCHANGE(115)` + `SEND_NEXT_DEVICE_CONFIG(116)` +/// — the read-only enumeration pair `ConfigKeySweep` builds — was answered by a WHOOP 5 MG with +/// `revision=1 count=7`, and the walk ran to a clean `index=255 validKey=false` terminator listing: +/// +/// ``` +/// sigproc_wear_detect enable_rfid max_collection_backlog +/// cont_collection_mode whoop_live_hr_in_adv_ind_pkt whoop_live_2_hrm_devices +/// enable_raw_data_w_ecg +/// ``` +/// +/// Six of those seven were unknown to this codebase. Nothing here is guessed: the names are the strap's +/// own, read off its own enumeration. +/// +/// ## Why this key, and why now +/// +/// #891 records that all three TOGGLE_LABRADOR (ECG) commands — 139, 125, 124 — answer `SUCCESS(1)` on a +/// WHOOP 5 MG and produce **zero** ECG packets in a 30-second listen. What the echoed byte those replies +/// carry actually MEANS is still open: arg-echo is refuted (SELECT_WRIST was sent 0 and answered 1) and +/// blanket payload-echo is refuted (GET_BATTERY_LEVEL sends `[0x00]` and answers 0x2F), but "read-back of +/// stored state" remains an inference — a per-opcode handler echoing a constant fits every observation so +/// far just as well. Either way the practical lesson holds: a `SUCCESS` result byte is not evidence that +/// state changed. +/// +/// On the same strap `enable_raw_data_w_ecg` reads `'0'`. A device-config key whose name pairs "raw data" +/// with "ecg", sitting at `'0'` on a strap whose ECG toggles accept and emit nothing, is the leading +/// candidate for the gate. **Whether flipping it actually produces ECG data is UNKNOWN.** A negative +/// result — gate flipped to `'1'`, read back as `'1'`, still no packets — is a publishable answer that +/// removes the leading hypothesis from #891, and is the outcome this path is built to establish either way. +/// +/// ## Why a write is safe to offer at all +/// +/// Config writes on this firmware are demonstrated, not assumed: running the existing `enable_r22_*` +/// sequence (#174) moved `enable_sig12` from `'2'` (0x32) to `'1'` (0x31), confirmed by a 121 read before +/// and after. So a `SET_DEVICE_CONFIG_VALUE(119)` write lands, and the value that comes back afterwards is +/// real rather than an echo of what was sent. +/// +/// ## Read-back is the proof, not the ack +/// +/// The write's own `COMMAND_RESPONSE` is **not** treated as evidence. #891 is the standing example of why: +/// `SELECT_WRIST` returns SUCCESS for a no-op and FAILURE for a real change, so a result byte says nothing +/// reliable about whether state moved. Every write from this path is therefore followed by a +/// `GET_DEVICE_CONFIG_VALUE(121)` read of the same key, and only the value that comes back is reported. +/// (Framing owed to @ryanbr on #891.) +/// +/// ## The allowlist is key-aware, not just opcode-aware +/// +/// Opcode 119 is shared: the Broadcast-HR flag (#181) writes through it too. An opcode-only allowlist +/// therefore cannot express "this key and no other", and before this file the 5/MG send path admitted ANY +/// device-config key while the Broadcast-HR opt-in happened to be on. `admitsSend` closes that: it parses +/// the key name out of the body and admits exactly two keys, each only while its OWN opt-in is on. The +/// five remaining enumerated keys are named in `outOfScopeKeys` and are refused unconditionally — their +/// effects are unknown and nothing here has any reason to move them. +/// +/// This is the same discipline `DeviceConfigReadProbe.isReadOnlyOpcode` established for the read probes: a +/// single 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. +/// +/// Pure: no CoreBluetooth, no I/O, no preferences. The app layer supplies the two opt-in booleans. The +/// Kotlin twin is `com.noop.protocol.DeviceConfigWriteGate` — keep them byte-identical. +public enum DeviceConfigWriteGate { + + // MARK: - Opcodes + + /// `SET_DEVICE_CONFIG_VALUE` (119 / 0x77) — writes ONE persistent device-config value. The only write + /// verb this gate ever admits, and only for the two keys below. + public static let setDeviceConfigValueCmd: UInt8 = 119 + + /// `SET_FF_VALUE` (120 / 0x78) — the FEATURE-FLAG write verb (the `enable_r22_*` sequence, #174). A + /// different namespace, and named here for exactly one reason: so `admitsSend` can be proved to refuse + /// it. Nothing on this path may ever send it. + public static let setFeatureFlagValueCmd: UInt8 = 120 + + /// `GET_DEVICE_CONFIG_VALUE` (121 / 0x79) — the read verb the mandatory post-write read-back uses. + /// Read-only; it is also in `DeviceConfigReadProbe.readOnlyOpcodes`. + public static let getDeviceConfigValueCmd: UInt8 = 121 + + // MARK: - Keys + + /// The key this file exists for. Written ONLY while the ECG-gate opt-in is on AND the strap has + /// positively attested itself a WHOOP MG (`Whoop5Variant.isMG`) — a plain 5.0 has no electrodes, so + /// the write has nothing to gate there. + public static let ecgRawDataKey = "enable_raw_data_w_ecg" + + /// The Broadcast-HR key (#181), hardware-validated since a Garmin Edge 840 paired to it. Admitted only + /// while ITS own opt-in is on — restated here so the gate can express one rule per key rather than one + /// rule per opcode. + public static let broadcastHrKey = "whoop_live_hr_in_adv_ind_pkt" + + /// The other five keys the strap's 115/116 enumeration listed. Each is refused unconditionally: their + /// effects are undocumented and unmeasured, and `max_collection_backlog` in particular reads `"0.0"`, + /// which is not even a flag. Listed rather than merely omitted so the refusal is testable and so a + /// future edit that wants one of them has to delete a line deliberately. + public static let outOfScopeKeys: [String] = [ + "sigproc_wear_detect", + "enable_rfid", + "max_collection_backlog", + "cont_collection_mode", + "whoop_live_2_hrm_devices", + ] + + /// Every device-config key the strap enumerated, in the order it served them. Reported in the UI and + /// used by tests; never used to build a write. + public static let enumeratedKeys: [String] = [ + "sigproc_wear_detect", + "enable_rfid", + "max_collection_backlog", + "cont_collection_mode", + broadcastHrKey, + "whoop_live_2_hrm_devices", + ecgRawDataKey, + ] + + // MARK: - Values + + /// ASCII `'1'` — the gate on. + public static let enabledValue: UInt8 = 0x31 + /// ASCII `'0'` — the gate off, and what a subscription-free MG reads today. + public static let disabledValue: UInt8 = 0x30 + + /// The value byte for a requested state. + public static func value(on: Bool) -> UInt8 { on ? enabledValue : disabledValue } + + /// The value byte rendered as the character the strap stores. + public static func valueString(on: Bool) -> String { on ? "1" : "0" } + + // MARK: - Body parsing + + /// Width of the key-name field in a device-config body (`Whoop5Config.deviceConfigBody` NUL-pads to + /// this). Shared with `DeviceConfigReadProbe.nameFieldBytes`. + public static let nameFieldBytes = 32 + + /// The key name carried by a `SET_DEVICE_CONFIG_VALUE` payload, or nil when the payload is not shaped + /// like one. + /// + /// The payload the send path holds is `[0x01] + deviceConfigBody(...)`: the inner b3 byte, then the + /// 32-byte NUL-padded name, then the value. 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. + public static func keyName(inSendPayload payload: [UInt8]) -> String? { + guard payload.count >= 1 + nameFieldBytes, 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 { + switch key { + case ecgRawDataKey: return ecgGateOptIn && isMG + case broadcastHrKey: return broadcastHrOptIn + default: return false + } + } + + /// **The send allowlist itself.** True only for `SET_DEVICE_CONFIG_VALUE(119)` carrying a well-formed + /// body whose key passes `isWritableKey`. + /// + /// Every other opcode is false — explicitly including `SET_FF_VALUE(120)`, which the R22 sequence + /// keeps its own separate clause for and which must never be reachable from here. + public static func admitsSend(opcode: UInt8, + payload: [UInt8], + ecgGateOptIn: Bool, + isMG: Bool, + broadcastHrOptIn: Bool) -> Bool { + guard opcode == setDeviceConfigValueCmd else { return false } + guard let key = keyName(inSendPayload: payload) else { return false } + return isWritableKey(key, ecgGateOptIn: ecgGateOptIn, isMG: isMG, broadcastHrOptIn: broadcastHrOptIn) + } + + /// The read verb the post-write verification is allowed to send, and only that one. 128 + /// (`GET_FF_VALUE`) is the other namespace's read verb and is not needed here. + public static func isReadBackOpcode(_ opcode: UInt8) -> Bool { opcode == getDeviceConfigValueCmd } + + // MARK: - Frames + + /// The `SET_DEVICE_CONFIG_VALUE(119)` payload that sets the ECG gate. + /// + /// Deliberately the SAME 33-byte single-value body the Broadcast-HR write has used on real hardware + /// since #181 — `[0x01] + [name NUL-padded to 32][value]`. The strap serves multi-character values + /// (`max_collection_backlog` reads `"0.0"`), so the READ side must handle them; but this key's observed + /// value is a single ASCII digit and a one-character write is the shape hardware has already accepted. + /// Writing a longer value is not needed here and is not inferred into existence. + public static func writePayload(on: Bool) -> [UInt8] { + [0x01] + Whoop5Config.deviceConfigBody(name: ecgRawDataKey, value: value(on: on)) + } + + /// The `GET_DEVICE_CONFIG_VALUE(121)` payload that reads the ECG gate back. Same request body the + /// read probe uses, so both paths ask in exactly one way. + public static func readBackPayload() -> [UInt8] { + DeviceConfigReadProbe.requestBody(key: ecgRawDataKey) + } +} + +// MARK: - Report + +/// The result of one ECG-gate write + mandatory read-back, as a copyable report. +/// +/// Order-dependent and pure (`noteWriteAck` → `noteReadBack`/`noteReadBackTimeout` → `render`), so +/// `swift test` covers the whole verdict table without a strap. Kotlin twin: +/// `EcgRawDataGateReport` in `android/…/protocol/DeviceConfigWriteGate.kt`; the rendered text is +/// byte-identical across platforms so a shared strap log reads the same either side. +public struct EcgRawDataGateReport: Equatable, Sendable { + + /// What the run established. The headline, and deliberately blunt about the case that matters: + /// a write whose ack said SUCCESS but whose read-back did not move is `.unchanged`, not success. + public enum Verdict: String, Equatable, Sendable { + /// Read-back returned exactly the value that was requested. The only success case. + case confirmed + /// Read-back returned a DIFFERENT value than requested — the write did not take. + case unchanged + /// The strap answered the read-back but did not echo the key, so no value can be claimed. + case notClaimed + /// The strap refused the read-back verb, or answered FAILURE for the key. + case refused + /// No reply to the read-back inside its window. + case silent + /// A reply arrived that could not be decoded (CRC, envelope, short record). + case undecodable + /// The read-back has not resolved yet. + case pending + } + + /// The value that was requested, as the strap stores it ("1" or "0"). + public let requested: String + /// Result code of the WRITE's own COMMAND_RESPONSE, when one arrived. Recorded, never trusted. + public private(set) var writeResultCode: Int? + /// The value the read-back actually returned. nil means "no value claimed", never "zero". + public private(set) var storedValue: String? + /// Result code of the READ-BACK's COMMAND_RESPONSE. + public private(set) var readBackResultCode: Int? + /// Raw read-back record bytes as hex, always reported whatever else decodes. + public private(set) var readBackRecordHex: String? + /// Trace lines, one per round-trip. + public private(set) var trace: [String] = [] + /// The verdict so far. + public private(set) var verdict: Verdict = .pending + + public init(on: Bool) { + self.requested = DeviceConfigWriteGate.valueString(on: on) + trace.append("SET_DEVICE_CONFIG_VALUE(119) key=\"\(DeviceConfigWriteGate.ecgRawDataKey)\" value='\(requested)' sent") + } + + /// Record the write's own ack. It is logged and NOT used to decide anything: #891 established that a + /// SUCCESS result code does not prove state changed. + public mutating func noteWriteAck(resultCode: Int?) { + writeResultCode = resultCode + let label = resultCode.map { "\(FeatureFlagProbe.resultLabel($0))(\($0))" } ?? "(unlabelled)" + trace.append("write ack → result=\(label) — recorded, not treated as proof; the read-back below is the proof") + } + + /// Record the decoded read-back and reach a verdict. + public mutating func noteReadBack(_ r: DeviceConfigReadProbe.ValueResponse) { + readBackResultCode = r.resultCode + readBackRecordHex = r.recordHex + let stored = r.stringValue(for: DeviceConfigWriteGate.ecgRawDataKey) + storedValue = stored + var line = "GET_DEVICE_CONFIG_VALUE(121) key=\"\(DeviceConfigWriteGate.ecgRawDataKey)\"" + if let c = r.resultCode { line += " → result=\(FeatureFlagProbe.resultLabel(c))(\(c))" } else { line += " →" } + if let stored { line += " value='\(stored)'" } + line += " record=[\(r.recordHex)]" + trace.append(line) + + if r.isUnsupported || r.isFailure { + verdict = .refused + } else if let stored { + verdict = stored == requested ? .confirmed : .unchanged + } else { + verdict = .notClaimed + } + } + + /// Record a read-back reply that could not be decoded. + public mutating func noteReadBackFailure(_ f: DeviceConfigReadProbe.ParseFailure) { + 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("read-back reply not decoded: \(why)") + verdict = .undecodable + } + + /// Record the strap answering nothing at all to the read-back. + public mutating func noteReadBackTimeout(seconds: Int) { + trace.append("GET_DEVICE_CONFIG_VALUE(121) → no COMMAND_RESPONSE within \(seconds)s") + verdict = .silent + } + + /// One-line summary, suitable for a Settings row. + public var summary: String { + switch verdict { + case .confirmed: + return "Strap now reports \(DeviceConfigWriteGate.ecgRawDataKey)='\(requested)' (read back, not just acked)." + case .unchanged: + return "Write did NOT take: asked for '\(requested)', strap still reports '\(storedValue ?? "?")'." + case .notClaimed: + return "Strap answered the read-back but did not echo the key, so no value is claimed." + case .refused: + return "Strap refused the read-back for this key — the stored value is unknown." + case .silent: + return "No reply to the read-back — the stored value is unknown." + case .undecodable: + return "The read-back reply did not decode — the stored value is unknown." + case .pending: + return "Waiting for the read-back…" + } + } + + /// The full copyable report. + public func render() -> String { + var sb = "#891 ECG RAW-DATA GATE — WHOOP MG\n" + sb += "Key: \(DeviceConfigWriteGate.ecgRawDataKey) (the strap's own 115/116 enumeration listed it)\n" + sb += "Wrote '\(requested)' via SET_DEVICE_CONFIG_VALUE(119), then read it back with " + sb += "GET_DEVICE_CONFIG_VALUE(121). SET_FF_VALUE(120) is never sent from this path, and no other " + sb += "device-config key is writable from it.\n" + sb += "\nVerdict: \(verdict.rawValue) — \(summary)\n" + sb += "\nExchange:\n" + for line in trace { sb += " " + line + "\n" } + sb += "\nWhether this gate actually produces ECG data is UNKNOWN. If it now reads '1' and a " + sb += "TOGGLE_LABRADOR listen still yields zero packets, that is a real result for #891 — please " + sb += "share this report there either way.\n" + return sb + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift index 408a4241ec..0d2032bc76 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift @@ -311,6 +311,85 @@ final class DeviceConfigReadProbeTests: XCTestCase { XCTAssertTrue(report.verdict.hasPrefix("1 config key name(s) found that NOOP did not have")) } + /// A successful enumeration is evidence about what the firmware HOLDS, not proof of what it would + /// ACCEPT — and it says nothing at all about the feature-flag namespace, where most of the catalogue + /// is aimed. So a run can be asked to sweep anyway, and then the candidate steps must actually happen. + func testAForcedSweepAsksTheCandidatesEvenAfterEnumerationSucceeds() { + var report = DeviceConfigReadProbeReport( + family: .whoop5, + knownFlagKeys: ["enable_r22_packets", "hr_ch_switching"], + batch: ConfigKeySweep.batch(from: 0, limit: 2), + forceCandidateSweep: true) + XCTAssertTrue(report.runsCandidateSweep) + + guard report.nextStep() != nil else { return XCTFail("s1") } + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 1))) + guard report.nextStep() != nil else { return XCTFail("s2") } + // The Broadcast-HR key: enumerated, and one NOOP already had — so `newKeysFound` stays empty and + // the headline is free to report what the sweep established rather than what enumeration found. + XCTAssertTrue(report.noteEnumerationNext( + nextReply(enumNext(index: 1, key: "whoop_live_hr_in_adv_ind_pkt")))) + guard report.nextStep() != nil else { return XCTFail("s3") } + XCTAssertFalse(report.noteEnumerationNext(nextReply(enumNext(index: 0xFF, key: nil, validKey: false)))) + XCTAssertEqual(report.enumerationVerb, .answered) + XCTAssertFalse(report.enumeratedKeys.isEmpty) + + var candidates: [String] = [] + var guard_ = 0 + while let step = report.nextStep(), guard_ < 200 { + guard_ += 1 + if step.group == .candidate { candidates.append(step.key) } + // Every candidate answers FAILURE(0) — "the firmware has no key by this name". + let code = step.group == .candidate ? 0 : 1 + report.noteReply(.init(resultCode: code, record: echoRecord(step.key, value: 0x30)), for: step) + } + // Both verbs answered in this fixture, so each name appears once per verb. + var distinct: [String] = [] + for k in candidates where !distinct.contains(k) { distinct.append(k) } + XCTAssertEqual(distinct, ConfigKeySweep.batch(from: 0, limit: 2).candidates.map(\.key)) + // The report must SAY the sweep was forced, and must not claim enumeration settled the question. + let text = report.render() + XCTAssertTrue(text.contains("FULL SWEEP: asked even though enumeration succeeded")) + XCTAssertFalse(text.contains("skipped — the strap enumerated its own device-config keys")) + // A fully-negative forced sweep against a fully-enumerated strap is the strong clean negative. + XCTAssertTrue(report.verdict.contains("clean negative"), report.verdict) + XCTAssertTrue(report.verdict.contains("enumerated in full"), report.verdict) + } + + /// The default is unchanged: forcing is opt-in, so an ordinary run still skips the sweep and still + /// tells the reader that a forced run is available. + func testTheSweepStaysAFallbackByDefaultAndSaysAForcedRunExists() { + var report = smallReport() + XCTAssertTrue(report.runsCandidateSweep, "with nothing enumerated yet the sweep is still on the table") + guard report.nextStep() != nil else { return XCTFail("s1") } + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 1))) + guard report.nextStep() != nil else { return XCTFail("s2") } + XCTAssertTrue(report.noteEnumerationNext(nextReply(enumNext(index: 1, key: "enable_rfid")))) + guard report.nextStep() != nil else { return XCTFail("s3") } + XCTAssertFalse(report.noteEnumerationNext(nextReply(enumNext(index: 0xFF, key: nil, validKey: false)))) + XCTAssertFalse(report.runsCandidateSweep, "an enumerated namespace turns the fallback off") + + var guard_ = 0 + while let step = report.nextStep(), guard_ < 200 { + guard_ += 1 + XCTAssertNotEqual(step.group, .candidate) + report.noteReply(.init(resultCode: 1, record: echoRecord(step.key, value: 0x32)), for: step) + } + let text = report.render() + XCTAssertTrue(text.contains("skipped — the strap enumerated its own device-config keys")) + XCTAssertTrue(text.contains("re-run with the full name sweep")) + XCTAssertFalse(text.contains("FULL SWEEP")) + } + + /// The `sig` line is the family the forced sweep exists to reach: it lives in the FEATURE-FLAG + /// namespace, which the device-config enumeration never covers. + func testTheCatalogueStillLeadsWithTheSigSeriesInTheFeatureFlagNamespace() { + let sig = ConfigKeySweep.catalogue.filter { $0.derivation == .sigSeries } + XCTAssertFalse(sig.isEmpty) + XCTAssertTrue(sig.allSatisfy { $0.namespace == .featureFlag }) + XCTAssertEqual(ConfigKeySweep.catalogue.first?.derivation, .sigSeries) + } + /// The #874 discipline, inherited: the strap's own end marker stops the walk, but a name OUR parser /// declines is counted and stepped over — one bad entry must not throw away every key after it. func testAnUndecodableNameIsSteppedOverRatherThanEndingTheWalk() { @@ -431,12 +510,38 @@ final class DeviceConfigReadProbeTests: XCTestCase { guard let next = report.nextStep() else { break } step = next } - XCTAssertEqual(asked, ["enable_sig1", "enable_sig2"]) + // Each NAME is asked through every verb that answered — here both, so each appears twice. That is + // the point: a name asked only through the verb its namespace guess named would answer FAILURE + // from the wrong namespace and be indistinguishable from "no such key". + XCTAssertEqual(asked, ["enable_sig1", "enable_sig1", "enable_sig2", "enable_sig2"]) + XCTAssertEqual(Set(asked.map { $0 }).count, 2) + // The verdict counts NAMES, not round-trips. XCTAssertEqual(report.verdict, "asked 2 candidate key name(s); this firmware has none of them (a clean negative)") XCTAssertTrue(report.newKeysFound.isEmpty) } + /// The routing fix itself: every candidate is asked through BOTH value verbs when both answered, and + /// through only the survivor when one is dead. + func testEveryCandidateIsAskedThroughEveryAnsweringVerb() { + var (report, first) = driveToCandidates(limit: 2) + guard var step: DeviceConfigReadProbeReport.Step = first else { return XCTFail("no candidate") } + var byKey: [String: [UInt8]] = [:] + while true { + byKey[step.key, default: []].append(step.opcode) + report.noteReply(.init(resultCode: 0, record: []), for: step) + guard let next = report.nextStep() else { break } + step = next + } + for (key, opcodes) in byKey { + XCTAssertEqual(Set(opcodes), [121, 128], "\(key) must be asked through both verbs") + } + // And the per-name rendering shows which verb said what, rather than collapsing them. + let text = report.render() + XCTAssertTrue(text.contains("121=unknown · 128=unknown"), text) + XCTAssertTrue(text.contains("each name asked through 2 verb(s)"), text) + } + /// And a hit is the headline, named in the verdict so a strap log's first line carries the finding. func testACandidateThatExistsBecomesTheHeadline() { var (report, first) = driveToCandidates(limit: 2) @@ -481,7 +586,10 @@ final class DeviceConfigReadProbeTests: XCTestCase { report.noteReply(.init(resultCode: step.group == .candidate ? 0 : 1, record: echoRecord(step.key, value: 0x32)), for: step) } - XCTAssertEqual(candidates, ConfigKeySweep.catalogue.count) + // One round-trip per (name × answering verb) — both verbs answered here, so the whole catalogue + // costs twice its length. This is also the case that proves the safety cap was raised enough: + // the old cap of 128 would have truncated this run and reported it as "cap reached". + XCTAssertEqual(candidates, ConfigKeySweep.catalogue.count * 2) XCTAssertNil(report.stopReason, "a full default run must not hit the safety cap") XCTAssertTrue(report.render().contains("none untested")) } @@ -541,10 +649,12 @@ final class DeviceConfigReadProbeTests: XCTestCase { report.noteReply(.init(resultCode: 0, record: [0x01, 0x00]), for: s5) guard let s6 = report.nextStep() else { return XCTFail("s6") } report.noteReply(.init(resultCode: 1, record: echoRecord("hr_ch_switching", value: 0x32)), for: s6) - guard let c1 = report.nextStep() else { return XCTFail("c1") } - report.noteReply(.init(resultCode: 0, record: [0x01, 0x00]), for: c1) - guard let c2 = report.nextStep() else { return XCTFail("c2") } - report.noteReply(.init(resultCode: 0, record: [0x01, 0x00]), for: c2) + // Two names × two answering verbs = four candidate round-trips. + for label in ["c1", "c2", "c3", "c4"] { + guard let c = report.nextStep() else { return XCTFail(label) } + XCTAssertEqual(c.group, .candidate) + report.noteReply(.init(resultCode: 0, record: [0x01, 0x00]), for: c) + } XCTAssertNil(report.nextStep()) XCTAssertEqual(report.render(), DeviceConfigReadProbeTests.goldenReport) @@ -579,11 +689,11 @@ Known key values (the flags NOOP writes, plus anything enumeration returned) (1) 1. hr_ch_switching = '2' (0x32) Candidate key names — GUESSES, never observed on a wire or in any table (2 asked of 54 in the catalogue; 52 untested): - 0 exist · 2 do not · 0 inconclusive + 0 exist · 2 do not · 0 inconclusive (each name asked through 2 verb(s)) sig series (T8) — the firmware numbers its signal chains; sig11/sig12 are the two we have (2): - 1. enable_sig1 unknown - 2. enable_sig2 unknown + 1. enable_sig1 121=unknown · 128=unknown + 2. enable_sig2 121=unknown · 128=unknown Run the probe again to continue from catalogue entry 3. diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigWriteGateTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigWriteGateTests.swift new file mode 100644 index 0000000000..2f11dbf649 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigWriteGateTests.swift @@ -0,0 +1,339 @@ +import XCTest +@testable import WhoopProtocol + +/// #891: the ECG raw-data gate — the write allowlist, the body it builds, and the mandatory read-back. +/// +/// The allowlist tests are the point of this file. `DeviceConfigWriteGate.admitsSend` is the SAME +/// predicate the 5/MG send path consults, so proving here that it refuses an opcode or a key is proving +/// it about the real wire path rather than about a copy of the rule. +final class DeviceConfigWriteGateTests: XCTestCase { + + // MARK: - Helpers + + /// The payload the send path would hold for a device-config write of `key`. + private func payload(key: String, value: UInt8 = 0x31) -> [UInt8] { + [0x01] + Whoop5Config.deviceConfigBody(name: key, value: value) + } + + /// A real 5/MG COMMAND_RESPONSE frame carrying `record` for command 121, CRC16 header + CRC32 body, + /// so `DeviceConfigReadProbe.parse` runs its CRC gate on these fixtures rather than being bypassed. + /// Same construction as the #103 read-probe tests. + private func readBackFrame(record: [UInt8], cmd: UInt8 = 121, result: UInt8 = 1) -> [UInt8] { + var inner: [UInt8] = [36, 1, cmd, 0x0A, result] + record // type, seq, cmd, 2-byte hdr, record + let pad = (4 - inner.count % 4) % 4 + if pad > 0 { inner += [UInt8](repeating: 0, count: pad) } + let declLen = inner.count + 4 + var frame: [UInt8] = [0xAA, 0x01, UInt8(declLen & 0xFF), UInt8((declLen >> 8) & 0xFF), 0x00, 0x01] + let c16 = crc16Modbus(Array(frame[0..<6])) + frame += [UInt8(c16 & 0xFF), UInt8((c16 >> 8) & 0xFF)] + frame += inner + let c32 = crc32(inner) + frame += [UInt8(c32 & 0xFF), UInt8((c32 >> 8) & 0xFF), UInt8((c32 >> 16) & 0xFF), UInt8((c32 >> 24) & 0xFF)] + return frame + } + + /// A 121 reply record echoing `key` in a 32-byte NUL-padded field, then `value` as ASCII. + private func echoRecord(key: String, value: String) -> [UInt8] { + var rec = [UInt8](repeating: 0, count: DeviceConfigWriteGate.nameFieldBytes) + for (i, b) in Array(key.utf8).enumerated() where i < rec.count { rec[i] = b } + return rec + Array(value.utf8) + } + + // MARK: - The allowlist: what it admits + + func testAdmitsEcgKeyOnlyWhenOptedInOnAnAttestedMG() { + let p = payload(key: DeviceConfigWriteGate.ecgRawDataKey) + // The one combination that is allowed. + XCTAssertTrue(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: true, isMG: true, broadcastHrOptIn: false)) + // Opt-in off — a default install can never form these bytes. + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: false, isMG: true, broadcastHrOptIn: false)) + // Opted in but the strap is not an attested MG (a plain 5.0, or DIS not read yet). + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: true, isMG: false, broadcastHrOptIn: false)) + // The Broadcast-HR opt-in must NOT carry the ECG key — that cross-authorisation is the exact + // hole the key-aware gate closes. + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: false, isMG: true, broadcastHrOptIn: true)) + } + + func testAdmitsBroadcastHrKeyOnlyUnderItsOwnOptIn() { + let p = payload(key: DeviceConfigWriteGate.broadcastHrKey) + XCTAssertTrue(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: false, isMG: false, broadcastHrOptIn: true)) + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: false, isMG: false, broadcastHrOptIn: false)) + // The ECG opt-in must not carry the Broadcast-HR key either — the rule runs both ways. + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: true, isMG: true, broadcastHrOptIn: false)) + // Broadcast HR is not MG-gated: it works on a plain 5.0, and must keep doing so. + XCTAssertTrue(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: true, isMG: false, broadcastHrOptIn: true)) + } + + // MARK: - The allowlist: what it refuses + + func testRefusesEveryOtherEnumeratedKeyEvenWithBothOptInsOn() { + // The five keys the strap listed that this app has no business writing. Every one refused, with + // both opt-ins on and an attested MG — the most permissive state that exists. + for key in DeviceConfigWriteGate.outOfScopeKeys { + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: payload(key: key), + ecgGateOptIn: true, isMG: true, broadcastHrOptIn: true), + "\(key) must never be writable from this path") + } + XCTAssertEqual(DeviceConfigWriteGate.outOfScopeKeys.count, 5) + // The five out-of-scope keys plus the two writable ones are exactly what the strap enumerated. + XCTAssertEqual(Set(DeviceConfigWriteGate.outOfScopeKeys) + .union([DeviceConfigWriteGate.ecgRawDataKey, DeviceConfigWriteGate.broadcastHrKey]), + Set(DeviceConfigWriteGate.enumeratedKeys)) + XCTAssertEqual(DeviceConfigWriteGate.enumeratedKeys.count, 7) + } + + func testRefusesSetFeatureFlagValue120ForEveryKeyAndEveryOptIn() { + // 120 is the OTHER namespace's write verb (the R22 sequence). It must be unreachable from here + // no matter what the body says or which opt-ins are on. + for key in DeviceConfigWriteGate.enumeratedKeys + Whoop5Config.enableR22Sequence.map(\.name) { + for ecg in [true, false] { + for hr in [true, false] { + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: DeviceConfigWriteGate.setFeatureFlagValueCmd, payload: payload(key: key), + ecgGateOptIn: ecg, isMG: true, broadcastHrOptIn: hr), + "SET_FF_VALUE(120) must never be admitted (key=\(key))") + } + } + } + } + + func testRefusesEveryOtherOpcodeInTheWholeByteRange() { + // Exhaustive: with both opt-ins on, an attested MG, and a body carrying the one writable key, + // 119 is the ONLY opcode this gate admits. All 255 others are refused. + let p = payload(key: DeviceConfigWriteGate.ecgRawDataKey) + var admitted: [UInt8] = [] + for opcode in UInt8.min...UInt8.max { + if DeviceConfigWriteGate.admitsSend(opcode: opcode, payload: p, + ecgGateOptIn: true, isMG: true, broadcastHrOptIn: true) { + admitted.append(opcode) + } + } + XCTAssertEqual(admitted, [DeviceConfigWriteGate.setDeviceConfigValueCmd]) + } + + func testRefusesUnknownAndMalformedKeys() { + let states = [(true, true), (true, false), (false, true), (false, false)] + for (ecg, hr) in states { + // A key nobody has ever seen. + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: payload(key: "enable_something_invented"), + ecgGateOptIn: ecg, isMG: true, broadcastHrOptIn: hr)) + // A near-miss on the real key: prefix, suffix, and case all matter. + for near in ["enable_raw_data_w_ec", "enable_raw_data_w_ecg2", "ENABLE_RAW_DATA_W_ECG"] { + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: payload(key: near), + ecgGateOptIn: ecg, isMG: true, broadcastHrOptIn: hr), "\(near) must not pass") + } + } + // Bodies that are not shaped like a device-config write are refused rather than guessed at. + let good = payload(key: DeviceConfigWriteGate.ecgRawDataKey) + for bad: [UInt8] in [ + [], // empty + [0x01], // b3 byte only + Array(good.dropFirst()), // missing the b3 byte + [0x02] + Array(good.dropFirst()), // wrong b3 byte + Array(good.prefix(20)), // truncated inside the name field + ] { + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: bad, ecgGateOptIn: true, isMG: true, broadcastHrOptIn: true)) + } + } + + func testKeyNameParsingRejectsNonNulPaddingAndNonAscii() { + // A name field with junk AFTER the NUL terminator is not a NUL-padded name field, so nothing is + // claimed — a body cannot smuggle a second string past the terminator. + var body = payload(key: DeviceConfigWriteGate.ecgRawDataKey) + body[1 + DeviceConfigWriteGate.ecgRawDataKey.count + 2] = 0x41 // 'A' amongst the padding + XCTAssertNil(DeviceConfigWriteGate.keyName(inSendPayload: body)) + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: body, ecgGateOptIn: true, isMG: true, broadcastHrOptIn: true)) + // Extending the name itself yields a DIFFERENT name, which the key allowlist then refuses. + var extended = payload(key: DeviceConfigWriteGate.ecgRawDataKey) + extended[1 + DeviceConfigWriteGate.ecgRawDataKey.count] = 0x41 + XCTAssertEqual(DeviceConfigWriteGate.keyName(inSendPayload: extended), "enable_raw_data_w_ecgA") + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: extended, ecgGateOptIn: true, isMG: true, broadcastHrOptIn: true)) + // Binary in the name field is refused, not transliterated. + var binary = payload(key: DeviceConfigWriteGate.ecgRawDataKey) + binary[3] = 0xFF + XCTAssertNil(DeviceConfigWriteGate.keyName(inSendPayload: binary)) + // The happy path still round-trips. + XCTAssertEqual(DeviceConfigWriteGate.keyName(inSendPayload: payload(key: "enable_rfid")), + "enable_rfid") + } + + func testReadBackOpcodeIsOnly121() { + var admitted: [UInt8] = [] + for opcode in UInt8.min...UInt8.max where DeviceConfigWriteGate.isReadBackOpcode(opcode) { + admitted.append(opcode) + } + XCTAssertEqual(admitted, [121]) + // Notably NOT the write verbs, and not the other namespace's read verb. + XCTAssertFalse(DeviceConfigWriteGate.isReadBackOpcode(119)) + XCTAssertFalse(DeviceConfigWriteGate.isReadBackOpcode(120)) + XCTAssertFalse(DeviceConfigWriteGate.isReadBackOpcode(128)) + } + + // MARK: - The bytes on the wire + + func testWritePayloadIsTheHardwareValidatedBroadcastHrShape() { + let on = DeviceConfigWriteGate.writePayload(on: true) + // [b3] + [32-byte NUL-padded name] + [value] — 34 bytes, the same shape #181 has written since. + XCTAssertEqual(on.count, 1 + 32 + 1) + XCTAssertEqual(on[0], 0x01) + XCTAssertEqual(DeviceConfigWriteGate.keyName(inSendPayload: on), "enable_raw_data_w_ecg") + XCTAssertEqual(on.last, 0x31) // ASCII '1' + let off = DeviceConfigWriteGate.writePayload(on: false) + XCTAssertEqual(off.last, 0x30) // ASCII '0' + // Both directions differ ONLY in the value byte — reversibility is one byte, not a second path. + XCTAssertEqual(Array(on.dropLast()), Array(off.dropLast())) + } + + func testReadBackPayloadMatchesTheReadProbesRequestShape() { + XCTAssertEqual(DeviceConfigWriteGate.readBackPayload(), + DeviceConfigReadProbe.requestBody(key: "enable_raw_data_w_ecg")) + } + + // MARK: - Multi-character values (max_collection_backlog = "0.0") + + func testStringValueReadsMultiCharacterValues() { + let rec = echoRecord(key: "max_collection_backlog", value: "0.0") + let r = DeviceConfigReadProbe.ValueResponse(resultCode: 1, record: rec) + // The single-byte reader loses the rest; the string reader is what a comparison must use. + XCTAssertEqual(r.value(for: "max_collection_backlog"), 0x30) + XCTAssertEqual(r.stringValue(for: "max_collection_backlog"), "0.0") + } + + func testStringValueStopsAtNulSoEnvelopePaddingIsNotRead() { + // The puffin envelope pads the inner payload to a 4-byte boundary; those NULs are not data. + let rec = echoRecord(key: "enable_raw_data_w_ecg", value: "1") + [0, 0, 0] + let r = DeviceConfigReadProbe.ValueResponse(resultCode: 1, record: rec) + XCTAssertEqual(r.stringValue(for: "enable_raw_data_w_ecg"), "1") + } + + func testStringValueClaimsNothingWhenThereIsNothingToClaim() { + // Key not echoed at all. + let other = DeviceConfigReadProbe.ValueResponse(resultCode: 1, + record: echoRecord(key: "enable_rfid", value: "0")) + XCTAssertNil(other.stringValue(for: "enable_raw_data_w_ecg")) + // Name field present but the record stops there — no value follows. + let bare = DeviceConfigReadProbe.ValueResponse( + resultCode: 1, record: [UInt8](repeating: 0, count: 32)) + XCTAssertNil(bare.stringValue(for: "enable_raw_data_w_ecg")) + // A value slot that starts with NUL is empty, not "". + let empty = DeviceConfigReadProbe.ValueResponse( + resultCode: 1, record: echoRecord(key: "enable_raw_data_w_ecg", value: "") + [0]) + XCTAssertNil(empty.stringValue(for: "enable_raw_data_w_ecg")) + // Non-ASCII is refused rather than transliterated. + let binary = DeviceConfigReadProbe.ValueResponse( + resultCode: 1, record: echoRecord(key: "enable_raw_data_w_ecg", value: "") + [0xFF, 0]) + XCTAssertNil(binary.stringValue(for: "enable_raw_data_w_ecg")) + } + + // MARK: - The verdict table: the ack never decides, the read-back does + + func testConfirmedOnlyWhenTheReadBackReturnsWhatWasAsked() { + var report = EcgRawDataGateReport(on: true) + XCTAssertEqual(report.verdict, .pending) + report.noteWriteAck(resultCode: 1) + // A SUCCESS ack alone must NOT reach a verdict — #891's whole lesson. + XCTAssertEqual(report.verdict, .pending) + let frame = readBackFrame(record: echoRecord(key: "enable_raw_data_w_ecg", value: "1")) + guard case .success(let r) = DeviceConfigReadProbe.parse(frame: frame, family: .whoop5, + expecting: 121) else { + return XCTFail("read-back frame should parse") + } + report.noteReadBack(r) + XCTAssertEqual(report.verdict, .confirmed) + XCTAssertEqual(report.storedValue, "1") + XCTAssertTrue(report.render().contains("enable_raw_data_w_ecg")) + } + + func testSuccessAckWithAnUnmovedValueIsUnchangedNotSuccess() { + // The exact failure mode this design exists for: the strap acks SUCCESS, and the value did not + // move. Reporting that as success is what the read-back is here to prevent. + var report = EcgRawDataGateReport(on: true) + report.noteWriteAck(resultCode: 1) + let frame = readBackFrame(record: echoRecord(key: "enable_raw_data_w_ecg", value: "0")) + guard case .success(let r) = DeviceConfigReadProbe.parse(frame: frame, family: .whoop5, + expecting: 121) else { + return XCTFail("read-back frame should parse") + } + report.noteReadBack(r) + XCTAssertEqual(report.verdict, .unchanged) + XCTAssertEqual(report.storedValue, "0") + XCTAssertTrue(report.summary.contains("did NOT take")) + } + + func testTurningTheGateBackOffConfirmsOnZero() { + var report = EcgRawDataGateReport(on: false) + XCTAssertEqual(report.requested, "0") + let frame = readBackFrame(record: echoRecord(key: "enable_raw_data_w_ecg", value: "0")) + guard case .success(let r) = DeviceConfigReadProbe.parse(frame: frame, family: .whoop5, + expecting: 121) else { + return XCTFail("read-back frame should parse") + } + report.noteReadBack(r) + XCTAssertEqual(report.verdict, .confirmed) + } + + func testRefusedSilentNotClaimedAndUndecodableAreNeverSuccess() { + // FAILURE for the key. + var refused = EcgRawDataGateReport(on: true) + refused.noteReadBack(DeviceConfigReadProbe.ValueResponse(resultCode: 0, record: [])) + XCTAssertEqual(refused.verdict, .refused) + // UNSUPPORTED verb. + var unsupported = EcgRawDataGateReport(on: true) + unsupported.noteReadBack(DeviceConfigReadProbe.ValueResponse(resultCode: 3, record: [])) + XCTAssertEqual(unsupported.verdict, .refused) + // Answered, but the key was not echoed — no value can be claimed. + var notClaimed = EcgRawDataGateReport(on: true) + notClaimed.noteReadBack(DeviceConfigReadProbe.ValueResponse( + resultCode: 1, record: echoRecord(key: "enable_rfid", value: "1"))) + XCTAssertEqual(notClaimed.verdict, .notClaimed) + // No reply at all. + var silent = EcgRawDataGateReport(on: true) + silent.noteReadBackTimeout(seconds: 8) + XCTAssertEqual(silent.verdict, .silent) + // A reply that failed the CRC gate. + var undecodable = EcgRawDataGateReport(on: true) + undecodable.noteReadBackFailure(.crc) + XCTAssertEqual(undecodable.verdict, .undecodable) + for r in [refused, unsupported, notClaimed, silent, undecodable] { + XCTAssertNotEqual(r.verdict, .confirmed) + XCTAssertFalse(r.summary.isEmpty) + } + } + + func testReadBackIsCrcGatedLikeEveryOtherDecode() { + var frame = readBackFrame(record: echoRecord(key: "enable_raw_data_w_ecg", value: "1")) + frame[frame.count - 1] ^= 0xFF // corrupt the CRC32 trailer + guard case .failure(let f) = DeviceConfigReadProbe.parse(frame: frame, family: .whoop5, + expecting: 121) else { + return XCTFail("a corrupt frame must not decode") + } + XCTAssertEqual(f, .crc) + } + + func testRenderNamesTheKeyTheVerbsAndTheOpenQuestion() { + var report = EcgRawDataGateReport(on: true) + report.noteWriteAck(resultCode: 1) + report.noteReadBackTimeout(seconds: 8) + let text = report.render() + XCTAssertTrue(text.contains("SET_DEVICE_CONFIG_VALUE(119)")) + XCTAssertTrue(text.contains("GET_DEVICE_CONFIG_VALUE(121)")) + XCTAssertTrue(text.contains("SET_FF_VALUE(120) is never sent")) + // The honest framing has to survive into the copyable report a user pastes into an issue. + XCTAssertTrue(text.contains("UNKNOWN")) + XCTAssertTrue(text.contains("#891")) + } +} diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 6974ea30a7..5561befbbb 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -850,7 +850,9 @@ final class AppModel: ObservableObject { // #103: READ-ONLY device-config READ probe (121/128) — asks the strap for a key's VALUE, the // follow-up to #761's key-NAME enumeration. Writes nothing. User-initiated, Test-Centre-gated in // DevicesView. - func probeDeviceConfigValues() { ble.probeDeviceConfigValues() } + func probeDeviceConfigValues(forceCandidateSweep: Bool = false) { + ble.probeDeviceConfigValues(forceCandidateSweep: forceCandidateSweep) + } func clearDeviceConfigProbe() { ble.clearDeviceConfigProbe() } /// Drop the current strap and clear bond state so a newly-picked strap model connects fresh diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index e1a04675ab..cd3e2b0a2f 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1539,10 +1539,28 @@ public final class BLEManager: NSObject, ObservableObject { // data the strap emits) and is what the official app sends. Driven only by // enableWhoop5DeepData(). (#174) || (command == .setConfig && PuffinExperiment.deepDataEnabled) - // 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) - || (command == .setDeviceConfig && PuffinExperiment.broadcastHrEnabled) else { + // SET_DEVICE_CONFIG_VALUE (119) writes ONE persistent device-config value. Opcode 119 is + // shared by more than one feature, so an opcode-only clause cannot say "this key and no + // other" — and the clause this replaced admitted ANY device-config key whenever the + // Broadcast-HR opt-in happened to be on. `DeviceConfigWriteGate.admitsSend` parses the key + // NAME out of the body and admits exactly two, each only while its OWN opt-in is on: + // `whoop_live_hr_in_adv_ind_pkt` (#181, driven by setBroadcastHr(_:)) and + // `enable_raw_data_w_ecg` (#891, driven by setEcgRawDataGate(_:), which additionally + // requires the strap to have attested itself an MG). The other five keys the strap's own + // 115/116 enumeration listed are refused unconditionally, and SET_FF_VALUE(120) is refused + // by this predicate outright — the R22 sequence keeps its separate clause above. Same + // discipline as DeviceConfigReadProbe.isReadOnlyOpcode: ONE pure predicate that the send + // path itself consults, so the unit tests that prove what it rejects are proving it about + // this wire path rather than about a copy of the rule. + || DeviceConfigWriteGate.admitsSend(opcode: command.rawValue, + payload: payload, + ecgGateOptIn: PuffinExperiment.ecgRawDataEnabled, + isMG: whoop5Variant.isMG, + broadcastHrOptIn: PuffinExperiment.broadcastHrEnabled) + // GET_DEVICE_CONFIG_VALUE (121) as the ECG gate's mandatory post-write read-back. Allowed + // ONLY while a verification is actually in flight, the same in-flight shape the read + // probes use, and narrowed to 121 alone (isReadBackOpcode) rather than the probe's four. + || (DeviceConfigWriteGate.isReadBackOpcode(command.rawValue) && ecgGateReport != nil) else { log("send(\(command.label)) skipped — no WHOOP 5/MG framing for this command yet") return } @@ -2433,6 +2451,123 @@ public final class BLEManager: NSObject, ObservableObject { log("Broadcast HR: wrote whoop_live_hr_in_adv_ind_pkt=\(on ? "1" : "0")") } + // MARK: #891 ECG raw-data gate (the one WRITE this issue's investigation needs) + + /// Per-step reply window for the read-back. One round-trip, so this bounds the whole verification. + private static let ecgGateReadBackTimeout: TimeInterval = 8 + + /// The in-flight write+verify report; nil when none is running. Doubles as the send() allowlist's + /// in-flight gate — 121 cannot leave the app from this path unless this is non-nil. + private var ecgGateReport: EcgRawDataGateReport? + /// Monotonic step counter so a late timeout can't cancel a newer verification. + private var ecgGateStep = 0 + + /// EXPERIMENTAL (#891): write the device-config key `enable_raw_data_w_ecg` on a WHOOP MG, then READ + /// IT BACK and report what the strap actually stores. + /// + /// ## What this does + /// + /// One `SET_DEVICE_CONFIG_VALUE(119)` write of a single ASCII digit — `'1'` on, `'0'` off — to one + /// named key, followed by one `GET_DEVICE_CONFIG_VALUE(121)` read of the same key. Nothing else is + /// written; the other five keys the strap enumerated are refused by + /// `DeviceConfigWriteGate.admitsSend`, and `SET_FF_VALUE(120)` is unreachable from here. + /// + /// ## Why it might matter + /// + /// #891: all three TOGGLE_LABRADOR (ECG) commands ack SUCCESS on a WHOOP MG and produce zero packets + /// in a 30-second listen. On that same strap this key reads `'0'`. It is the leading candidate for the + /// gate — and **whether flipping it produces ECG data is UNKNOWN**. A confirmed `'1'` with still no + /// packets is a real answer for #891, not a failed attempt. + /// + /// ## Why the read-back is not optional + /// + /// The write's own ack is recorded and NOT believed. #891 established that a result byte can be a + /// read-back of stored state rather than an acknowledgement of a change — SELECT_WRIST returns SUCCESS + /// for a no-op and FAILURE for a real one — so only the 121 read proves anything. (Read-back framing + /// owed to @ryanbr on #891.) + /// + /// ## Gates + /// + /// Opt-in ON, strap positively attested MG over DIS (`.unknown` is not MG — a plain 5.0 has no + /// electrodes), 5/MG family, connected, and the genuine encrypted bond: like the R22 writes (#269), + /// a config write over the live-HR-only link silently fails. Not wear-gated — this stores a value, + /// it does not start an on-wrist stream. Reversible in one call with `on: false`. + public func setEcgRawDataGate(_ on: Bool) { + guard selectedModel.deviceFamily == .whoop5 else { + log("ECG gate (#891): needs a WHOOP 5/MG strap selected — ignored."); return + } + guard PuffinExperiment.ecgRawDataEnabled else { + log("ECG gate (#891): the experiment is off — enable it in Settings → Experimental first."); return + } + let variant = whoop5Variant + guard variant.isMG else { + // Refuse rather than guess. A plain 5.0 has no ECG electrodes, and `.unknown` means the strap + // has not said what it is — either way this key has nothing to gate. + log("ECG gate (#891): the strap has not attested itself an MG over DIS (variant=\(variant.label)) — ignored. A plain WHOOP 5.0 has no ECG electrodes.") + return + } + guard state.connected, state.encryptedBond else { + log("ECG gate (#891): 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 ecgGateReport == nil else { + log("ECG gate (#891): a write is already being verified — ignored."); return + } + + ecgGateReport = EcgRawDataGateReport(on: on) + state.ecgRawDataGate = ecgGateReport + log("ECG gate (#891): writing \(DeviceConfigWriteGate.ecgRawDataKey)='\(DeviceConfigWriteGate.valueString(on: on))' via SET_DEVICE_CONFIG_VALUE(119) on an attested MG; the write ack will NOT be reported as the result — a GET_DEVICE_CONFIG_VALUE(121) read-back follows.") + send(.setDeviceConfig, payload: DeviceConfigWriteGate.writePayload(on: on), writeType: .withResponse) + + // Read back after a short settle. 200 ms is the same order the R22 sequence spaces its writes at; + // the strap has to have committed the value before a read can prove anything. + ecgGateStep &+= 1 + let armed = ecgGateStep + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in + guard let self, self.ecgGateReport != nil, self.ecgGateStep == armed else { return } + self.send(.getDeviceConfigValue, payload: DeviceConfigWriteGate.readBackPayload()) + DispatchQueue.main.asyncAfter(deadline: .now() + BLEManager.ecgGateReadBackTimeout) { [weak self] in + guard let self, self.ecgGateReport != nil, self.ecgGateStep == armed else { return } + self.ecgGateReport?.noteReadBackTimeout(seconds: Int(BLEManager.ecgGateReadBackTimeout)) + self.finishEcgGateWrite() + } + } + } + + /// Publish + log the finished write/verify report and re-close the send() allowlist. + private func finishEcgGateWrite() { + guard let report = ecgGateReport else { return } + ecgGateReport = nil + state.ecgRawDataGate = report + log("ECG gate (#891):\n\(report.render())") + } + + /// Clear the #891 result (Settings row dismissed / disconnect). Twin of Android clearEcgRawDataGate(). + public func clearEcgRawDataGate() { state.ecgRawDataGate = nil } + + /// #891: the WRITE's own COMMAND_RESPONSE. Recorded for the transcript and deliberately NOT used to + /// decide the verdict — the 121 read-back below is what settles it. + private func handleEcgGateWriteAck(_ frame: [UInt8], cmdOff: Int) { + guard ecgGateReport != nil else { return } + // pay[1] is the 5/MG result code, at cmdOff + 2 (cmd byte, then the 2-byte response header). + let resultIndex = cmdOff + 2 + let code: Int? = frame.count > resultIndex ? Int(frame[resultIndex]) : nil + ecgGateReport?.noteWriteAck(resultCode: code) + state.ecgRawDataGate = ecgGateReport + } + + /// #891: the read-back COMMAND_RESPONSE for 121. In-flight-guarded, and parsed by the same pure + /// `DeviceConfigReadProbe.parse` the read probe uses — including its CRC gate. + private func handleEcgGateReadBack(_ frame: [UInt8], isWhoop5: Bool) { + guard ecgGateReport != nil else { return } + let family: DeviceFamily = isWhoop5 ? .whoop5 : .whoop4 + switch DeviceConfigReadProbe.parse(frame: frame, family: family, + expecting: DeviceConfigWriteGate.getDeviceConfigValueCmd) { + case .success(let r): ecgGateReport?.noteReadBack(r) + case .failure(let f): ecgGateReport?.noteReadBackFailure(f) + } + finishEcgGateWrite() + } + /// Read the strap's current BLE advertising name (WHOOP 4.0 / Harvard). The reply lands as a /// GET_ADVERTISING_NAME COMMAND_RESPONSE and FrameRouter publishes it to `LiveState.advertisingName`. /// Also sent automatically in the connect handshake; this is the manual refresh the Settings card @@ -2733,7 +2868,15 @@ public final class BLEManager: NSObject, ObservableObject { /// useful — it is what makes guessing the only available method. Result goes to /// `LiveState.deviceConfigProbe` (the Devices dialog) and to the strap log — no new storage. /// User-initiated only, Test Centre → Connection gated. Twin of Android `probeDeviceConfigValues()`. - public func probeDeviceConfigValues() { + /// + /// - Parameter forceCandidateSweep: ask the guessed-name catalogue EVEN IF enumeration succeeded. + /// Off by default because it costs one round-trip per name. It exists because a successful + /// enumeration does not close the question: enumeration reports the keys the firmware HOLDS, a key + /// it would accept but has never stored a value for need not appear, and the oracle cannot tell that + /// case from "no such key" (both answer FAILURE). It also says nothing about the FEATURE-FLAG + /// namespace, where most of the catalogue is aimed — including the `sig` line, the family the + /// strap's own console tag (`SIGPROC: generated a valid SPO2 during sleep`) points at. + public func probeDeviceConfigValues(forceCandidateSweep: Bool = false) { guard state.connected else { log("Device-config read probe (#103) ignored — not connected") return @@ -2752,9 +2895,13 @@ public final class BLEManager: NSObject, ObservableObject { family: selectedModel.deviceFamily, // The flag names come from NOOP's own R22 sequence — never restated here. knownFlagKeys: Whoop5Config.enableR22Sequence.map(\.name), - batch: ConfigKeySweep.batch(from: configKeySweepCursor)) + batch: ConfigKeySweep.batch(from: configKeySweepCursor), + forceCandidateSweep: forceCandidateSweep) state.deviceConfigProbe = BLEManager.deviceConfigProbeWaiting - log("Config key probe (#103): enumerating device-config keys via START_DEVICE_CONFIG_KEY_EXCHANGE(115)/SEND_NEXT_DEVICE_CONFIG(116), then reading VALUES via GET_DEVICE_CONFIG_VALUE(121)/GET_FF_VALUE(128) on family=\(selectedModel.deviceFamily); read-only (SET_FF_VALUE/120 and SET_DEVICE_CONFIG_VALUE/119 are never sent from this path)") + let sweepNote = forceCandidateSweep + ? "; FULL SWEEP — the \(ConfigKeySweep.catalogue.count)-name candidate catalogue will be asked even if enumeration succeeds" + : "" + log("Config key probe (#103): enumerating device-config keys via START_DEVICE_CONFIG_KEY_EXCHANGE(115)/SEND_NEXT_DEVICE_CONFIG(116), then reading VALUES via GET_DEVICE_CONFIG_VALUE(121)/GET_FF_VALUE(128) on family=\(selectedModel.deviceFamily)\(sweepNote); read-only (SET_FF_VALUE/120 and SET_DEVICE_CONFIG_VALUE/119 are never sent from this path)") advanceDeviceConfigProbe() } @@ -3258,11 +3405,21 @@ public final class BLEManager: NSObject, ObservableObject { /// information content here) — never the full string, which would land in a shareable strap log. private func noteWhoop5VariantFromDIS() { let variant = Whoop5Variant.from(serial: disSerial, hardwareRevision: disHwRev) + state.whoop5Variant = variant let prefix = (disSerial?.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()) .map { String($0.prefix(3)) } ?? "?" log("DIS: serialPrefix=\(prefix) hwRev=\(disHwRev ?? "?") -> variant=\(variant.label)") } + /// The connected strap's attested 5-generation hardware variant, re-derived from the DIS strings the + /// connection read rather than cached — so it is `.unknown` before DIS lands and after a disconnect + /// clears them, and `.unknown` is never MG. This is the gate an MG-only capability asks (#891); it is + /// deliberately independent of `DeviceFamily`, which describes the WIRE PROTOCOL and treats MG and 5.0 + /// as one family. + public var whoop5Variant: Whoop5Variant { + Whoop5Variant.from(serial: disSerial, hardwareRevision: disHwRev) + } + private func requestNotify(_ c: CBCharacteristic, on p: CBPeripheral, reason: String) { guard c.properties.contains(.notify) || c.properties.contains(.indicate) else { log("Notify unavailable \(c.uuid) (\(reason))") @@ -3886,6 +4043,12 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // …and abandon a plan the link interrupted, re-closing the 121/128 send() allowlist. deviceConfigReport = nil deviceConfigAwaiting = nil + state.ecgRawDataGate = nil // #891: drop a stale write/verify result on disconnect + // …and abandon a verification the link interrupted, re-closing the 119/121 send() allowlist. An + // unverified write must never be left showing a verdict it never reached. + ecgGateReport = nil + // A disconnected strap has attested nothing, so the MG-only gate closes with the link. + state.whoop5Variant = .unknown 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 @@ -4798,6 +4961,18 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { DeviceConfigReadProbe.isReadOnlyOpcode(frame[10]) { handleDeviceConfigProbeResponse(frame, isWhoop5: true) } + // #891: the ECG gate's own two replies — the SET_DEVICE_CONFIG_VALUE(119) write ack + // (recorded, never believed) and the GET_DEVICE_CONFIG_VALUE(121) read-back that is + // the actual proof. Both in-flight-guarded inside, so these are byte compares on every + // other frame. 121 is deliberately handled here as well as by the probe hook above: + // the two paths guard on DIFFERENT in-flight sentinels, so exactly one of them acts. + if frame.count > 10, frame[8] == 0x24 { + if frame[10] == WhoopCommand.setDeviceConfig.rawValue { + handleEcgGateWriteAck(frame, cmdOff: 10) + } else if frame[10] == WhoopCommand.getDeviceConfigValue.rawValue { + handleEcgGateReadBack(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..dd40055d87 100644 --- a/Strand/BLE/LiveState.swift +++ b/Strand/BLE/LiveState.swift @@ -256,6 +256,18 @@ 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 + + /// #891: the result of the last `enable_raw_data_w_ecg` write, AFTER its mandatory + /// `GET_DEVICE_CONFIG_VALUE(121)` read-back — the write's own ack is never reported as the outcome. + /// nil until a write is attempted; cleared on disconnect. Twin of the Android + /// WhoopBleClient.ecgRawDataGate flow. + @Published public var ecgRawDataGate: EcgRawDataGateReport? = nil + + /// #520/#891: which WHOOP 5-generation hardware the connected strap attested itself over the Device + /// Information Service. `.unknown` until the DIS strings land — and `.unknown` is NOT MG, so an + /// MG-only action stays refused until the hardware actually says so. Reset on disconnect. + @Published public var whoop5Variant: Whoop5Variant = .unknown + /// 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..c15a71e4bd 100644 --- a/Strand/BLE/PuffinExperiment.swift +++ b/Strand/BLE/PuffinExperiment.swift @@ -30,6 +30,25 @@ enum PuffinExperiment { static var broadcastHrEnabled: Bool { UserDefaults.standard.bool(forKey: broadcastHrKey) } + /// Opt-in "ECG raw-data gate" (#891): writes the device-config key `enable_raw_data_w_ecg` — the key + /// the strap's own 115/116 enumeration listed, and which reads `'0'` on a subscription-free WHOOP MG + /// whose three TOGGLE_LABRADOR commands all ack SUCCESS and emit nothing. + /// + /// Its own key rather than a shared "ECG" one, for two reasons. First, this repo gives every + /// PERSISTENT STRAP WRITE its own deliberate opt-in — `deepDataKey` (#174) and `broadcastHrKey` (#181) + /// are both separate from the read-only `defaultsKey` probes for exactly that reason, and reusing one + /// switch for "listen for ECG packets" and "change a stored value on the strap" would let the second + /// ride in on consent given for the first. Second, the ECG listen toggles are not on main: they live on + /// an unmerged branch, so there is no existing key here to reuse. + /// + /// Reversible in one tap, default OFF, and additionally gated on `Whoop5Variant.isMG` at the call site + /// — a plain 5.0 has no electrodes. Driven only by `BLEManager.setEcgRawDataGate(_:)`, which always + /// follows the write with a `GET_DEVICE_CONFIG_VALUE(121)` read-back. Mirrors the Android + /// `PuffinExperiment.KEY_ECG_RAW_DATA`. + static let ecgRawDataKey = "noopEcgRawDataGate" + + static var ecgRawDataEnabled: Bool { UserDefaults.standard.bool(forKey: ecgRawDataKey) } + /// Opt-in "Continuous HRV capture": hold the dense realtime HR stream armed even with no Live screen /// open, so the strap banks beat-to-beat R-R intervals 24/7 for far better overnight HRV/recovery/ /// sleep (vs the sparse history offload). Uses more battery (continuous HR streaming). Default OFF; @@ -152,6 +171,7 @@ enum PuffinExperiment { defaultsKey, // protocol probes deepDataKey, // R22 deep-data strap write broadcastHrKey, // broadcast-HR write + ecgRawDataKey, // enable_raw_data_w_ecg strap write (#891) PuffinFrameRecorder.enabledKey, // raw frame capture — declared on PuffinFrameRecorder ] diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index fcfecc1755..6ccc36e248 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -170616,6 +170616,318 @@ } } } + }, + "ECG raw-data gate (WHOOP MG only)": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "ECG-Rohdatensperre (nur WHOOP MG)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Bloqueo de datos ECG sin procesar (solo WHOOP MG)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Verrou des données ECG brutes (WHOOP MG uniquement)" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Bloqueio de dados ECG em bruto (apenas WHOOP MG)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Blocco dei dati ECG grezzi (solo WHOOP MG)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Блокировка сырых данных ЭКГ (только WHOOP MG)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "ECG 原始数据开关(仅限 WHOOP MG)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "ECG 原始資料開關(僅限 WHOOP MG)" + } + } + } + }, + "Send probe + full name sweep (read-only, slower)": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Probe + vollständiger Namensdurchlauf senden (nur lesend, langsamer)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar sonda + barrido completo de nombres (solo lectura, más lento)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer la sonde + balayage complet des noms (lecture seule, plus lent)" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Enviar sonda + varrimento completo de nomes (apenas leitura, mais lento)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Invia sonda + scansione completa dei nomi (sola lettura, più lenta)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправить пробу + полный перебор имён (только чтение, медленнее)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发送探测 + 完整名称扫描(只读,较慢)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "傳送探測 + 完整名稱掃描(唯讀,較慢)" + } + } + } + }, + "This writes a setting that STAYS ON YOUR STRAP until you change it back — it isn't an app preference, and closing NOOP won't undo it. \"Turn gate off\" below writes '0' again, in one tap. Only this one key is ever written; the other six your strap listed are never touched.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dies schreibt eine Einstellung, die AUF DEINEM BAND BLEIBT, bis du sie zurücksetzt — es ist keine App-Einstellung, und das Schließen von NOOP macht sie nicht rückgängig. „Sperre ausschalten“ unten schreibt mit einem Tippen wieder '0'. Es wird ausschließlich dieser eine Schlüssel geschrieben; die anderen sechs, die dein Band aufgelistet hat, werden nie angefasst." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto escribe un ajuste que PERMANECE EN TU CORREA hasta que lo cambies de vuelta: no es una preferencia de la app y cerrar NOOP no lo deshace. «Desactivar bloqueo», abajo, vuelve a escribir '0' con un solo toque. Solo se escribe esta clave; las otras seis que enumeró tu correa nunca se tocan." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ceci écrit un réglage qui RESTE SUR VOTRE BRACELET jusqu'à ce que vous le remettiez — ce n'est pas une préférence de l'app, et fermer NOOP ne l'annule pas. « Désactiver le verrou », ci-dessous, réécrit « 0 » en une seule touche. Seule cette clé est écrite ; les six autres que votre bracelet a énumérées ne sont jamais touchées." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Isto escreve uma definição que FICA NA SUA PULSEIRA até a repor — não é uma preferência da aplicação e fechar o NOOP não a desfaz. «Desativar bloqueio», abaixo, volta a escrever '0' com um só toque. Só esta chave é escrita; as outras seis que a sua pulseira listou nunca são tocadas." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questo scrive un'impostazione che RESTA SULLA TUA FASCIA finché non la riporti indietro: non è una preferenza dell'app e chiudere NOOP non la annulla. «Disattiva blocco», qui sotto, riscrive '0' con un solo tocco. Viene scritta solo questa chiave; le altre sei elencate dalla fascia non vengono mai toccate." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Это записывает настройку, которая ОСТАЁТСЯ НА БРАСЛЕТЕ, пока вы не вернёте её обратно, — это не настройка приложения, и закрытие NOOP её не отменит. Кнопка «Выключить блокировку» ниже одним нажатием записывает '0' снова. Записывается только этот один ключ; остальные шесть, перечисленные браслетом, никогда не затрагиваются." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这会写入一项会一直留在你手环上的设置,直到你改回来——它不是应用内的偏好设置,关闭 NOOP 也不会撤销。下方的「关闭开关」一键即可再写入 '0'。全程只写入这一个键;你手环列出的另外六个从不触碰。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這會寫入一項會一直留在你手環上的設定,直到你改回來——它不是應用程式內的偏好設定,關閉 NOOP 也不會復原。下方的「關閉開關」一鍵即可再寫入 '0'。全程只寫入這一個鍵;你手環列出的另外六個從不觸碰。" + } + } + } + }, + "Turn gate off (write '0')": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sperre ausschalten ('0' schreiben)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivar bloqueo (escribir '0')" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactiver le verrou (écrire « 0 »)" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Desativar bloqueio (escrever '0')" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Disattiva blocco (scrivi '0')" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выключить блокировку (записать '0')" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭开关(写入 '0')" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉開關(寫入 '0')" + } + } + } + }, + "Turn gate on (write '1')": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sperre einschalten ('1' schreiben)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activar bloqueo (escribir '1')" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer le verrou (écrire « 1 »)" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Ativar bloqueio (escrever '1')" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva blocco (scrivi '1')" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить блокировку (записать '1')" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开开关(写入 '1')" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟開關(寫入 '1')" + } + } + } + }, + "Your strap listed its own device-config keys, and one of them is enable_raw_data_w_ecg. On an MG with no ECG subscription it reads '0' — while all three ECG commands answer SUCCESS and send no data at all (#891). This is the leading guess for what's holding ECG shut. Nobody knows whether flipping it actually produces ECG data: finding out is the point, and \"still nothing\" is a useful answer worth posting to #891.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dein Band hat seine eigenen Geräte-Konfigurationsschlüssel aufgelistet, darunter enable_raw_data_w_ecg. Auf einem MG ohne ECG-Abo steht er auf '0' — während alle drei ECG-Befehle mit SUCCESS antworten und trotzdem keine Daten senden (#891). Das ist die naheliegendste Vermutung, was ECG verschlossen hält. Ob das Umlegen tatsächlich ECG-Daten liefert, weiß niemand: genau das herauszufinden ist der Zweck, und „immer noch nichts“ ist ein brauchbares Ergebnis, das sich zu posten lohnt (#891)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tu correa ha enumerado sus propias claves de configuración, y una de ellas es enable_raw_data_w_ecg. En un MG sin suscripción de ECG vale '0', mientras que los tres comandos de ECG responden SUCCESS y no envían dato alguno (#891). Es la hipótesis principal sobre qué mantiene el ECG cerrado. Nadie sabe si cambiarla produce realmente datos de ECG: averiguarlo es el objetivo, y «sigue sin haber nada» es una respuesta útil que merece publicarse en #891." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Votre bracelet a énuméré ses propres clés de configuration, dont enable_raw_data_w_ecg. Sur un MG sans abonnement ECG, elle vaut « 0 » — alors que les trois commandes ECG répondent SUCCESS sans envoyer la moindre donnée (#891). C'est l'hypothèse principale sur ce qui maintient l'ECG fermé. Personne ne sait si la basculer produit réellement des données ECG : le savoir est justement le but, et « toujours rien » est une réponse utile à publier sur #891." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A sua pulseira listou as suas próprias chaves de configuração, e uma delas é enable_raw_data_w_ecg. Num MG sem subscrição de ECG lê '0' — enquanto os três comandos de ECG respondem SUCCESS e não enviam dados nenhuns (#891). Esta é a principal hipótese para o que mantém o ECG fechado. Ninguém sabe se mudá-la produz mesmo dados de ECG: descobrir é o objetivo, e «continua sem nada» é uma resposta útil que vale a pena publicar em #891." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La tua fascia ha elencato le proprie chiavi di configurazione, e una di esse è enable_raw_data_w_ecg. Su un MG senza abbonamento ECG vale '0', mentre tutti e tre i comandi ECG rispondono SUCCESS senza inviare alcun dato (#891). È l'ipotesi principale su cosa tenga chiuso l'ECG. Nessuno sa se cambiarla produca davvero dati ECG: scoprirlo è lo scopo, e «ancora nulla» è una risposta utile da pubblicare su #891." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ваш браслет перечислил собственные ключи конфигурации, и один из них — enable_raw_data_w_ecg. На MG без подписки на ЭКГ он равен '0', при этом все три команды ЭКГ отвечают SUCCESS и не присылают никаких данных (#891). Это главная догадка о том, что держит ЭКГ закрытым. Никто не знает, даст ли переключение реальные данные ЭКГ: выяснить это и есть цель, а «по-прежнему ничего» — полезный результат, который стоит опубликовать в #891." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你的手环列出了它自己的设备配置键,其中之一是 enable_raw_data_w_ecg。在没有 ECG 订阅的 MG 上它读作 '0',而三条 ECG 命令都回应 SUCCESS 却完全不发送数据 (#891)。这是目前对「什么在挡住 ECG」最有力的猜测。翻转它是否真能产生 ECG 数据,没有人知道:弄清楚正是目的,而「仍然没有」同样是值得发到 #891 的有用答案。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你的手環列出了它自己的裝置設定鍵,其中之一是 enable_raw_data_w_ecg。在沒有 ECG 訂閱的 MG 上它讀作 '0',而三條 ECG 命令都回應 SUCCESS 卻完全不傳送資料 (#891)。這是目前對「什麼擋住 ECG」最有力的猜測。翻轉它是否真能產生 ECG 資料,沒有人知道:弄清楚正是目的,而「仍然沒有」同樣是值得發到 #891 的有用答案。" + } + } + } } }, "version": "1.0" diff --git a/Strand/Screens/DevicesView.swift b/Strand/Screens/DevicesView.swift index 6629c5156d..aa558c27c6 100644 --- a/Strand/Screens/DevicesView.swift +++ b/Strand/Screens/DevicesView.swift @@ -1193,8 +1193,16 @@ private struct DeviceConfigProbeSheets: ViewModifier { titleVisibility: .visible, presenting: target) { _ in Button("Send probe (read-only)") { model.probeDeviceConfigValues(); target = nil } + // The forced sweep is its own explicit choice, never a fallback the probe takes on its + // own: it spends one round-trip per catalogue name, and a strap that enumerated cleanly + // would otherwise skip all of them. Still read-only — same two verbs, same allowlist. + Button("Send probe + full name sweep (read-only, slower)") { + model.probeDeviceConfigValues(forceCandidateSweep: true); target = nil + } Button("Cancel", role: .cancel) { target = nil } } message: { _ in + // Unchanged copy: editing it would orphan its existing translations in every locale, and + // what the full sweep adds is already carried by the button label + the report header. Text("Asks the strap for config VALUES: GET_DEVICE_CONFIG_VALUE (0x79) and GET_FF_VALUE (0x80), one key per round-trip. Both commands may simply not exist in this firmware — finding that out is the point. Read-only — no value is written, and the SET commands are never sent from this probe. The result is shown here and written to the strap log.") } .sheet(isPresented: Binding(get: { live.deviceConfigProbe != nil }, diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index c53b3182c5..72907fce9d 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -10,6 +10,9 @@ import PhotosUI import StrandDesign import StrandAnalytics import WhoopStore +// #891: EcgRawDataGateReport / Whoop5Variant — the ECG-gate row reports the strap's read-back verdict and +// gates on the attested hardware variant. +import WhoopProtocol /// Settings — profile (powers zones / calories / recovery), strap connection, and about. /// Grouped cards on surface.raised with a two-column form feel. @@ -41,6 +44,11 @@ struct SettingsView: View { /// BLE sensor for Garmin/Zwift/gym kit. See [PuffinExperiment.broadcastHrKey]. (#181) @AppStorage(PuffinExperiment.broadcastHrKey) private var broadcastHrEnabled = false + /// #891 opt-in: writes the device-config key `enable_raw_data_w_ecg` on an attested WHOOP MG. A + /// persistent strap write, so it gets its own deliberate switch like #174 and #181. + /// See [PuffinExperiment.ecgRawDataKey]. + @AppStorage(PuffinExperiment.ecgRawDataKey) private var ecgRawDataEnabled = false + /// Opt-in "Continuous HRV capture" (off by default) — holds the dense realtime stream armed 24/7 so /// the strap banks beat-to-beat R-R for better overnight HRV/recovery/sleep, at a battery cost. /// See [PuffinExperiment.keepRealtimeForDataKey]. @@ -1405,6 +1413,52 @@ struct SettingsView: View { #endif } + /// The #891 ECG-gate buttons need the same encrypted bond the R22 writes do (a config write over the + /// live-HR-only link silently fails, #269) AND a strap that has positively attested itself an MG. Not + /// wear-gated: this stores a value, it does not start an on-wrist stream. + private var ecgGateReady: Bool { + #if os(macOS) + return false + #else + return live.encryptedBond && live.whoop5Variant.isMG + #endif + } + + /// The reason line under the #891 buttons. Each case names the ONE thing that is missing. + private var ecgGateReason: String { + #if os(macOS) + return String(localized: "The ECG gate 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 a config write).") + } + if !live.whoop5Variant.isMG { + // `.unknown` lands here too, and deliberately: an unattested strap is not an MG. + return String(localized: "Waiting for your strap to identify itself as an MG. Only a WHOOP MG has ECG electrodes, so NOOP won't write this key to anything else.") + } + return String(localized: "One tap writes the key; NOOP then reads it back off the strap and reports the value it actually stores — the write's own \"success\" is not treated as proof.") + #endif + } + + /// Icon per read-back verdict. Only a confirmed read-back gets the success mark. + private func ecgGateIcon(_ v: EcgRawDataGateReport.Verdict) -> String { + switch v { + case .confirmed: return "checkmark.seal.fill" + case .unchanged: return "xmark.seal.fill" + case .pending: return "ellipsis" + default: return "questionmark.circle" + } + } + + /// Tint per read-back verdict. Anything that isn't a confirmed read-back is never shown as positive. + private func ecgGateTint(_ v: EcgRawDataGateReport.Verdict) -> Color { + switch v { + case .confirmed: return StrandPalette.statusPositive + case .unchanged: return StrandPalette.statusWarning + default: return StrandPalette.textSecondary + } + } + private var fiveMGCard: some View { SettingsSection( icon: "flask.fill", @@ -1503,6 +1557,57 @@ struct SettingsView: View { .accessibilityElement(children: .combine) } + Divider().overlay(StrandPalette.hairline) + + // MARK: #891 ECG raw-data gate — the second key this app may write, and MG-only. + Toggle(isOn: $ecgRawDataEnabled) { + Text("ECG raw-data gate (WHOOP MG only)") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + Text("Your strap listed its own device-config keys, and one of them is enable_raw_data_w_ecg. On an MG with no ECG subscription it reads '0' — while all three ECG commands answer SUCCESS and send no data at all (#891). This is the leading guess for what's holding ECG shut. Nobody knows whether flipping it actually produces ECG data: finding out is the point, and \"still nothing\" is a useful answer worth posting to #891.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + + if ecgRawDataEnabled { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(StrandPalette.statusWarning) + .accessibilityHidden(true) + Text("This writes a setting that STAYS ON YOUR STRAP until you change it back — it isn't an app preference, and closing NOOP won't undo it. \"Turn gate off\" below writes '0' again, in one tap. Only this one key is ever written; the other six your strap listed are never touched.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.statusWarning) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + + NoopButton("Turn gate on (write '1')", systemImage: "waveform.path.ecg", kind: .primary) { + model.ble.setEcgRawDataGate(true) + } + .disabled(!ecgGateReady) + NoopButton("Turn gate off (write '0')", systemImage: "arrow.uturn.backward", kind: .secondary) { + model.ble.setEcgRawDataGate(false) + } + .disabled(!ecgGateReady) + Text(ecgGateReason) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + + // The read-back — the only thing reported as a result. The write's own ack is in the + // strap log for the record and is deliberately not surfaced as an outcome here. + if let report = live.ecgRawDataGate { + Label(report.summary, systemImage: ecgGateIcon(report.verdict)) + .font(StrandFont.caption) + .foregroundStyle(ecgGateTint(report.verdict)) + .fixedSize(horizontal: false, vertical: true) + } + } + Toggle(isOn: $puffinCapture) { Text("Record puffin frames to a file") .font(StrandFont.subhead) diff --git a/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt b/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt index daa2a2512b..40661607c9 100644 --- a/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt +++ b/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt @@ -46,6 +46,23 @@ class PuffinExperiment(private val prefs: SharedPreferences) { get() = prefs.getBoolean(KEY_BROADCAST_HR, false) set(v) = prefs.edit().putBoolean(KEY_BROADCAST_HR, v).apply() + /** True if the user opted in to the "ECG raw-data gate" (#891): NOOP writes the device-config key + * `enable_raw_data_w_ecg` — the key the strap's own 115/116 enumeration listed, and which reads '0' on + * a subscription-free WHOOP MG whose three TOGGLE_LABRADOR commands all ack SUCCESS and emit nothing. + * + * Its own key rather than a shared "ECG" one, because this repo gives every PERSISTENT STRAP WRITE its + * own deliberate opt-in ([isDeepDataEnabled] #174, [broadcastHr] #181) — reusing one switch for + * "listen for ECG packets" and "change a stored value on the strap" would let the second ride in on + * consent given for the first. + * + * Reversible in one tap, default false, and additionally gated on `Whoop5Variant.isMG` at the call + * site — a plain 5.0 has no electrodes. Driven only by `WhoopBleClient.setEcgRawDataGate`, which always + * follows the write with a GET_DEVICE_CONFIG_VALUE(121) read-back. Mirrors the macOS + * `PuffinExperiment.ecgRawDataKey`. */ + var ecgRawData: Boolean + get() = prefs.getBoolean(KEY_ECG_RAW_DATA, false) + set(v) = prefs.edit().putBoolean(KEY_ECG_RAW_DATA, v).apply() + /** True if the user opted in to "Experimental sleep staging (V2)": detected nights are re-staged with * [com.noop.analytics.SleepStagerV2] (the transparent cardiorespiratory recipe, reimplemented from * contributor PR #600) instead of the default V1 [com.noop.analytics.SleepStager]. Pure analysis switch @@ -130,9 +147,14 @@ class PuffinExperiment(private val prefs: SharedPreferences) { /** "Broadcast heart rate" opt-in (mirrors macOS `PuffinExperiment.broadcastHrKey`). */ const val KEY_BROADCAST_HR = "noopBroadcastHr" + /** "ECG raw-data gate" opt-in — the `enable_raw_data_w_ecg` strap write (mirrors macOS + * `PuffinExperiment.ecgRawDataKey`). (#891) */ + const val KEY_ECG_RAW_DATA = "noopEcgRawDataGate" + /** The 5/MG-only probe keys, in ONE place: [resetFiveMGGatedProbes] clears exactly these, and * SettingsScreen watches exactly these for external writes. Two lists would drift. */ - internal val FIVE_MG_GATED_KEYS = listOf(KEY, KEY_CAPTURE, KEY_DEEP_DATA, KEY_BROADCAST_HR) + internal val FIVE_MG_GATED_KEYS = + listOf(KEY, KEY_CAPTURE, KEY_DEEP_DATA, KEY_BROADCAST_HR, KEY_ECG_RAW_DATA) /** "Experimental sleep staging (V2)" opt-in (mirrors macOS `PuffinExperiment.experimentalSleepV2Key`). */ const val KEY_EXPERIMENTAL_SLEEP_V2 = "noopExperimentalSleepV2" 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 5df8aa0ee3..c5cd13a1bd 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -41,6 +41,8 @@ import com.noop.protocol.ConfigKeySweep import com.noop.protocol.DeviceFamily import com.noop.protocol.DeviceConfigReadProbe import com.noop.protocol.DeviceConfigReadProbeReport +import com.noop.protocol.DeviceConfigWriteGate +import com.noop.protocol.EcgRawDataGateReport import com.noop.protocol.FeatureFlagProbe import com.noop.protocol.FeatureFlagProbeReport import com.noop.protocol.Framing @@ -1216,6 +1218,14 @@ class WhoopBleClient( * out), so this bounds one round-trip, not the whole plan. */ const val DEVICE_CONFIG_PROBE_TIMEOUT_MS = 8_000L + /** #891: settle before the ECG gate's read-back. Same order the R22 sequence spaces its writes at; + * the strap has to have committed the value before a read can prove anything. */ + const val ECG_GATE_SETTLE_MS = 200L + + /** #891: reply window for the ECG gate's read-back. One round-trip, so this bounds the whole + * verification. */ + const val ECG_GATE_READ_BACK_TIMEOUT_MS = 8_000L + /** * #690: format a GET_BODY_LOCATION_AND_STATUS (0x54) COMMAND_RESPONSE into a clean, readable, * copyable report — verdict, full raw hex, an offset-labelled payload grid, the four decoded fields @@ -1541,6 +1551,26 @@ class WhoopBleClient( private val _deviceConfigProbe = MutableStateFlow(null) val deviceConfigProbe: StateFlow = _deviceConfigProbe.asStateFlow() + /** #891: the result of the last `enable_raw_data_w_ecg` write, AFTER its mandatory + * GET_DEVICE_CONFIG_VALUE(121) read-back — the write's own ack is never reported as the outcome. + * null until a write is attempted; cleared on disconnect. Twin of the Swift + * `LiveState.ecgRawDataGate`. */ + private val _ecgRawDataGate = MutableStateFlow(null) + val ecgRawDataGate: StateFlow = _ecgRawDataGate.asStateFlow() + + /** #520/#891: which WHOOP 5-generation hardware the connected strap attested over DIS. UNKNOWN until + * the strings land — and UNKNOWN is NOT MG, so an MG-only action stays refused until the hardware + * actually says so. Reset on disconnect. */ + private val _whoop5Variant = MutableStateFlow(Whoop5Variant.UNKNOWN) + val whoop5VariantFlow: StateFlow = _whoop5Variant.asStateFlow() + + /** The in-flight #891 write+verify report; null when none is running. Doubles as the [send] allowlist's + * in-flight gate — 121 cannot leave the app from this path unless this is non-null. */ + private var ecgGateReport: EcgRawDataGateReport? = null + + /** Monotonic step counter so a late timeout can't cancel a newer verification. */ + private var ecgGateStep = 0 + /** The in-flight #103 report; null when no probe is running. Doubles as the [send] allowlist's * in-flight gate — 121/128 cannot leave the app unless this is non-null. */ private var deviceConfigReport: DeviceConfigReadProbeReport? = null @@ -2874,9 +2904,30 @@ class WhoopBleClient( // 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_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)) { + // SET_DEVICE_CONFIG_VALUE (119) writes ONE persistent device-config value. Opcode 119 is + // shared by more than one feature, so an opcode-only clause cannot say "this key and no + // other" — and the clause this replaced admitted ANY device-config key whenever the + // Broadcast-HR opt-in happened to be on. DeviceConfigWriteGate.admitsSend parses the key + // NAME out of the body and admits exactly two, each only while its OWN opt-in is on: + // whoop_live_hr_in_adv_ind_pkt (#181, driven by setBroadcastHr()) and + // enable_raw_data_w_ecg (#891, driven by setEcgRawDataGate(), which additionally requires + // the strap to have attested itself an MG). The other five keys the strap's own 115/116 + // enumeration listed are refused unconditionally, and SET_FF_VALUE(120) is refused by this + // predicate outright — the R22 sequence keeps its separate clause above. Same discipline as + // DeviceConfigReadProbe.isReadOnlyOpcode: ONE pure predicate that the send path itself + // consults, so the unit tests that prove what it rejects are proving it about this wire + // path rather than about a copy of the rule. + !DeviceConfigWriteGate.admitsSend( + opcode = cmd.rawValue, + payload = payload, + ecgGateOptIn = puffinExperiment.ecgRawData, + isMG = whoop5Variant().isMG, + broadcastHrOptIn = puffinExperiment.broadcastHr, + ) && + // GET_DEVICE_CONFIG_VALUE (121) as the ECG gate's mandatory post-write read-back. Allowed + // ONLY while a verification is actually in flight, the same in-flight shape the read probes + // use, and narrowed to 121 alone (isReadBackOpcode) rather than the probe's four. + !(DeviceConfigWriteGate.isReadBackOpcode(cmd.rawValue) && ecgGateReport != null)) { log("send(${cmd.name}) skipped — no WHOOP 5/MG framing for this command yet") return } @@ -3388,8 +3439,16 @@ class WhoopBleClient( * of guessed oxygen key names. The report goes to the Devices dialog and the strap log — no new * storage. User-initiated only, Test Centre → Connection gated at the call site. Twin of macOS * BLEManager.probeDeviceConfigValues(). + * + * @param forceCandidateSweep ask the guessed-name catalogue EVEN IF enumeration succeeded. Off by + * default because it costs one round-trip per name per answering verb. It exists because a + * successful enumeration does not close the question: enumeration reports the keys the firmware + * HOLDS, a key it would accept but has never stored a value for need not appear, and the oracle + * cannot tell that case from "no such key" (both answer FAILURE). It also says nothing about the + * FEATURE-FLAG namespace, where most of the catalogue is aimed — including the `sig` line, the + * family the strap's own console tag ("SIGPROC: generated a valid SPO2 during sleep") points at. */ - fun probeDeviceConfigValues() { + fun probeDeviceConfigValues(forceCandidateSweep: Boolean = false) { if (!_state.value.connected) { log("Device-config read probe (#103) ignored — not connected") return @@ -3409,13 +3468,20 @@ class WhoopBleClient( // The flag names come from NOOP's own R22 sequence — never restated here. Whoop5Config.enableR22Sequence.map { it.name }, ConfigKeySweep.batch(configKeySweepCursor), + forceCandidateSweep, ) _deviceConfigProbe.value = WAITING_DEVICE_CONFIG_PROBE + val sweepNote = if (forceCandidateSweep) { + "; FULL SWEEP — the ${ConfigKeySweep.CATALOGUE.size}-name candidate catalogue will be asked " + + "even if enumeration succeeds" + } else { + "" + } log( "Config key probe (#103): enumerating device-config keys via " + "START_DEVICE_CONFIG_KEY_EXCHANGE(115)/SEND_NEXT_DEVICE_CONFIG(116), then reading VALUES " + - "via GET_DEVICE_CONFIG_VALUE(121)/GET_FF_VALUE(128) on family=$connectedFamily; read-only " + - "(SET_FF_VALUE/120 and SET_DEVICE_CONFIG_VALUE/119 are never sent from this path)", + "via GET_DEVICE_CONFIG_VALUE(121)/GET_FF_VALUE(128) on family=$connectedFamily$sweepNote; " + + "read-only (SET_FF_VALUE/120 and SET_DEVICE_CONFIG_VALUE/119 are never sent from this path)", ) advanceDeviceConfigProbe() } @@ -3674,18 +3740,27 @@ class WhoopBleClient( } /** - * Resolve + log the 5/MG hardware variant from whatever DIS strings have landed (#520). Diagnostic - * only — nothing gates on it yet. + * Resolve + log the 5/MG hardware variant from whatever DIS strings have landed (#520). * * The serial is a device identifier, so ONLY its 3-character prefix is logged (that is the entire * information content here) — never the full string, which would end up in a shareable strap log. */ private fun noteWhoop5VariantFromDis() { - val variant = Whoop5Variant.from(disSerial, disHwRev) + val variant = whoop5Variant() + _whoop5Variant.value = variant val prefix = disSerial?.trim()?.uppercase()?.take(3) ?: "?" log("DIS: serialPrefix=$prefix hwRev=${disHwRev ?: "?"} -> variant=${variant.label}") } + /** + * The connected strap's attested 5-generation hardware variant, re-derived from the DIS strings the + * connection read rather than cached — so it is UNKNOWN before DIS lands and after a disconnect clears + * them, and UNKNOWN is never MG. This is the gate an MG-only capability asks (#891); it is deliberately + * independent of [DeviceFamily], which describes the WIRE PROTOCOL and treats MG and 5.0 as one family. + * Mirrors the Swift `BLEManager.whoop5Variant`. + */ + fun whoop5Variant(): Whoop5Variant = Whoop5Variant.from(disSerial, disHwRev) + fun refreshBattery() { val g = gatt if (g == null) { @@ -4867,6 +4942,19 @@ class WhoopBleClient( ) { handleDeviceConfigProbeResponse(frame) } + // #891: the ECG gate's own two replies — the SET_DEVICE_CONFIG_VALUE(119) write ack + // (recorded, never believed) and the GET_DEVICE_CONFIG_VALUE(121) read-back that is the + // actual proof. Both in-flight-guarded inside, so these are byte compares on every other + // frame. 121 is deliberately handled here as well as by the probe hook above: the two + // paths guard on DIFFERENT in-flight sentinels, so exactly one of them acts. + if (frame.size > cmdOff && (frame[cmdOff - 2].toInt() and 0xFF) == 0x24) { + val op = frame[cmdOff].toInt() and 0xFF + if (op == CommandNumber.SET_DEVICE_CONFIG.rawValue) { + handleEcgGateWriteAck(frame, cmdOff) + } else if (op == CommandNumber.GET_DEVICE_CONFIG_VALUE.rawValue) { + handleEcgGateReadBack(frame, connectedFamily == DeviceFamily.WHOOP5) + } + } 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 @@ -5654,6 +5742,126 @@ class WhoopBleClient( log("Broadcast HR: wrote whoop_live_hr_in_adv_ind_pkt=" + (if (on) "1" else "0")) } + /** + * EXPERIMENTAL (#891): write the device-config key `enable_raw_data_w_ecg` on a WHOOP MG, then READ IT + * BACK and report what the strap actually stores. Mirrors `BLEManager.setEcgRawDataGate`. + * + * One SET_DEVICE_CONFIG_VALUE(119) write of a single ASCII digit — '1' on, '0' off — to one named key, + * followed by one GET_DEVICE_CONFIG_VALUE(121) read of the same key. Nothing else is written: the other + * five keys the strap enumerated are refused by [DeviceConfigWriteGate.admitsSend], and SET_FF_VALUE + * (120) is unreachable from here. + * + * #891: all three TOGGLE_LABRADOR (ECG) commands ack SUCCESS on a WHOOP MG and produce zero packets in + * a 30-second listen. On that same strap this key reads '0'. It is the leading candidate for the gate — + * and **whether flipping it produces ECG data is UNKNOWN**. A confirmed '1' with still no packets is a + * real answer for #891, not a failed attempt. + * + * The write's own ack is recorded and NOT believed: #891 established that a result byte can be a + * read-back of stored state rather than an acknowledgement of a change, so only the 121 read proves + * anything. + * + * Gates: opt-in ON, strap positively attested MG over DIS (UNKNOWN is not MG — a plain 5.0 has no + * electrodes), 5/MG family, connected and bonded. Not wear-gated: this stores a value, it does not start + * an on-wrist stream. Reversible in one call with `on = false`. + */ + fun setEcgRawDataGate(on: Boolean) { + if (connectedFamily != DeviceFamily.WHOOP5) { + log("ECG gate (#891): needs a WHOOP 5/MG strap — ignored."); return + } + if (!puffinExperiment.ecgRawData) { + log("ECG gate (#891): the experiment is off — enable it in Settings → Experimental first.") + return + } + val variant = whoop5Variant() + if (!variant.isMG) { + // Refuse rather than guess. A plain 5.0 has no ECG electrodes, and UNKNOWN means the strap has + // not said what it is — either way this key has nothing to gate. + log( + "ECG gate (#891): the strap has not attested itself an MG over DIS " + + "(variant=${variant.label}) — ignored. A plain WHOOP 5.0 has no ECG electrodes.", + ) + return + } + val s = _state.value + if (!s.connected || !s.bonded) { + log("ECG gate (#891): connect and bond a 5/MG strap first — ignored."); return + } + if (ecgGateReport != null) { + log("ECG gate (#891): a write is already being verified — ignored."); return + } + + val report = EcgRawDataGateReport(on) + ecgGateReport = report + _ecgRawDataGate.value = report + log( + "ECG gate (#891): writing ${DeviceConfigWriteGate.ECG_RAW_DATA_KEY}=" + + "'${DeviceConfigWriteGate.valueString(on)}' via SET_DEVICE_CONFIG_VALUE(119) on an " + + "attested MG; the write ack will NOT be reported as the result — a " + + "GET_DEVICE_CONFIG_VALUE(121) read-back follows.", + ) + send( + CommandNumber.SET_DEVICE_CONFIG, + DeviceConfigWriteGate.writePayload(on), + withResponse = true, + ) + + // Read back after a short settle. 200 ms is the same order the R22 sequence spaces its writes at; + // the strap has to have committed the value before a read can prove anything. + ecgGateStep += 1 + val armed = ecgGateStep + handler.postDelayed({ + if (ecgGateReport == null || ecgGateStep != armed) return@postDelayed + send(CommandNumber.GET_DEVICE_CONFIG_VALUE, DeviceConfigWriteGate.readBackPayload()) + handler.postDelayed({ + if (ecgGateReport == null || ecgGateStep != armed) return@postDelayed + ecgGateReport?.noteReadBackTimeout((ECG_GATE_READ_BACK_TIMEOUT_MS / 1000).toInt()) + finishEcgGateWrite() + }, ECG_GATE_READ_BACK_TIMEOUT_MS) + }, ECG_GATE_SETTLE_MS) + } + + /** Publish + log the finished write/verify report and re-close the send() allowlist. */ + private fun finishEcgGateWrite() { + val report = ecgGateReport ?: return + ecgGateReport = null + _ecgRawDataGate.value = report + log("ECG gate (#891):\n${report.render()}") + } + + /** Clear the #891 result (Settings row dismissed / disconnect). Twin of Swift clearEcgRawDataGate(). */ + fun clearEcgRawDataGate() { _ecgRawDataGate.value = null } + + /** + * #891: the WRITE's own COMMAND_RESPONSE. Recorded for the transcript and deliberately NOT used to + * decide the verdict — the 121 read-back is what settles it. + */ + private fun handleEcgGateWriteAck(frame: ByteArray, cmdOff: Int) { + if (ecgGateReport == null) return + // The 5/MG result code is at cmdOff + 2 (cmd byte, then the 2-byte response header). + val resultIndex = cmdOff + 2 + val code = if (frame.size > resultIndex) (frame[resultIndex].toInt() and 0xFF) else null + ecgGateReport?.noteWriteAck(code) + _ecgRawDataGate.value = ecgGateReport + } + + /** + * #891: the read-back COMMAND_RESPONSE for 121. In-flight-guarded, and parsed by the same pure + * [DeviceConfigReadProbe.parse] the read probe uses — including its CRC gate. + */ + private fun handleEcgGateReadBack(frame: ByteArray, isWhoop5: Boolean) { + if (ecgGateReport == null) return + val family = if (isWhoop5) DeviceFamily.WHOOP5 else DeviceFamily.WHOOP4 + val parsed = DeviceConfigReadProbe.parse( + frame, family, DeviceConfigWriteGate.GET_DEVICE_CONFIG_VALUE_CMD, + ) + if (parsed.value != null) { + ecgGateReport?.noteReadBack(parsed.value) + } else if (parsed.failure != null) { + ecgGateReport?.noteReadBackFailure(parsed.failure) + } + finishEcgGateWrite() + } + /** * EXPERIMENTAL (#174): write the official app's `enable_r22_*` SET_CONFIG sequence to a bonded * WHOOP 5/MG to switch on the deep biometric (type-0x2F "R22") streams the strap withholds from a @@ -6866,6 +7074,12 @@ class WhoopBleClient( _deviceConfigProbe.value = null deviceConfigReport = null deviceConfigAwaiting = null + // #891: drop a stale write/verify result and abandon a verification the link interrupted, which + // re-closes the 119/121 send() allowlist. An unverified write must never be left showing a verdict + // it never reached. A disconnected strap has attested nothing, so the MG-only gate closes too. + _ecgRawDataGate.value = null + ecgGateReport = null + _whoop5Variant.value = Whoop5Variant.UNKNOWN reset() // close() can itself throw DeadObjectException on a dead binder — teardown must NEVER throw, diff --git a/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt b/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt index c2ce5759c4..9f46527bbe 100644 --- a/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt +++ b/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt @@ -107,11 +107,14 @@ object DeviceConfigReadProbe { /** Hard ceiling on round-trips in one probe, independent of how many keys the plan holds. The plan is * 1 enumerate-start + up to [ConfigKeySweep.MAX_ENUMERATION_STEPS] enumerate-next + 2 discovery + 2 - * cross-namespace + 16 known flags, then EITHER up to [ConfigKeySweep.MAX_ENUMERATED_VALUE_READS] - * value reads (when enumeration produced a list) OR up to [ConfigKeySweep.MAX_KEYS_PER_RUN] candidate - * names (when it did not) — never both, because guessing is pointless once the strap has handed over - * its own list. Worst case is 101 round-trips, comfortably under this. */ - const val MAX_STEPS = 128 + * cross-namespace + 16 known flags, plus up to [ConfigKeySweep.MAX_ENUMERATED_VALUE_READS] value + * reads when enumeration produced a list, and — when the sweep runs — every candidate name asked + * through EVERY answering verb. + * + * Raised from 128 when that per-verb fan-out landed: 2 × the catalogue on top of the rest passes 128, + * and the cap would have truncated the walk. It still says "safety cap reached", but a short sweep + * presented as a finished one is exactly the failure this number exists to prevent. */ + const val MAX_STEPS = 320 /** The one device-config key NOOP already knows a real strap accepts: the Broadcast-HR flag written * via SET_DEVICE_CONFIG_VALUE and hardware-validated in #181. Used as the discovery key for opcode @@ -199,6 +202,34 @@ object DeviceConfigReadProbe { return record[valueIndex].toInt() and 0xFF } + /** + * The value as the strap actually stores it — the WHOLE NUL-terminated ASCII string after the + * echoed name field, not just its first byte. + * + * Device-config values are **not** all single characters. A WHOOP 5 MG's own 115/116 enumeration + * listed `max_collection_backlog`, whose value reads `"0.0"` — three characters. [valueFor] would + * report that as `'0'` and quietly lose the rest, which is fine for a flag and wrong for anything + * else, so any caller comparing a value against what it asked for must use this. + * + * Stops at the first NUL, which is what keeps the puffin envelope's 4-byte-boundary padding out of + * the answer. Returns null — "no value claimed", never "empty" — when the key was not echoed, when + * nothing follows the name field, or when what follows is not printable ASCII. + * Keep in lockstep with the Swift `ValueResponse.stringValue(for:)`. + */ + fun stringValueFor(key: String): String? { + val off = echoOffset(key) ?: return null + val start = off + NAME_FIELD_BYTES + if (start >= record.size) return null + val out = StringBuilder() + for (i in start until record.size) { + val b = record[i].toInt() and 0xFF + if (b == 0) break + if (b < 0x20 || b > 0x7E) return null + out.append(b.toChar()) + } + return if (out.isEmpty()) null else out.toString() + } + // ByteArray fields need explicit equals/hashCode for a data class to compare by content. override fun equals(other: Any?): Boolean { if (this === other) return true @@ -274,8 +305,28 @@ class DeviceConfigReadProbeReport( private val knownFlagKeys: List, /** This run's slice of the candidate catalogue, and the cursor to hand the next run. */ val batch: ConfigKeySweep.Batch, + /** Run the candidate sweep EVEN IF enumeration succeeded. Default false — see [runsCandidateSweep]. */ + val forceCandidateSweep: Boolean = false, ) { + /** + * Whether this run will ask the guessed names at all. + * + * By default the sweep is a FALLBACK: a strap that enumerated its own device-config keys has already + * answered the question guessing was for, so the sweep is skipped. + * + * That default is right for the device-config namespace and **incomplete as a general claim**, which + * is why [forceCandidateSweep] exists. Enumeration reports the keys the firmware holds; a key it would + * accept but has never been given a value for need not be among them, and the oracle cannot separate + * that case from "no such key" because both answer `FAILURE(0)`. A successful enumeration is therefore + * evidence about what the strap HAS, not proof of what it would ACCEPT — and it says nothing at all + * about the FEATURE-FLAG namespace, which is where most of the catalogue is aimed. + * + * So the forced run stays available, and stays explicit: it costs one round-trip per catalogue name, + * which is not something to spend by default. + */ + val runsCandidateSweep: Boolean get() = forceCandidateSweep || _enumeratedKeys.isEmpty() + /** Which part of the plan a step belongs to. Drives both the ordering and the report's sections. */ enum class Group { ENUMERATE, DISCOVERY, CROSS_NAMESPACE, KNOWN_KEY, CANDIDATE } @@ -483,22 +534,48 @@ class DeviceConfigReadProbeReport( } 4 -> { // Guessing is the FALLBACK. If the strap enumerated its own device-config keys there is - // nothing to guess at in that namespace, so the sweep is skipped and said so in the report. - if (_enumeratedKeys.isNotEmpty() || cursor >= batch.candidates.size) { + // nothing to guess at in that namespace, so the sweep is skipped and said so in the report — + // unless this run was explicitly asked to sweep anyway (see [runsCandidateSweep] for why a + // successful enumeration does not close the question). + // + // EVERY candidate goes through EVERY answering verb, not through the one its `namespace` + // field guesses. That field is an author's expectation, and the namespaces are now PROVEN + // SEPARATE on hardware: 128 asked for a device-config key answers FAILURE, and 121 asked for a + // feature-flag key answers FAILURE. So a candidate that really is a device-config key, asked + // only through 128, comes back FAILURE and is indistinguishable from "no such key" — which + // would have made a negative sweep worthless for exactly the names it most needed to settle. + // Asking both costs one extra round-trip per name and is what lets a negative be called clean. + val verbs = candidateVerbs() + if (!runsCandidateSweep || verbs.isEmpty()) { null } else { - val candidate = batch.candidates[cursor] - val verb = verbFor(candidate.namespace) - if (verb == null) { + val idx = cursor / verbs.size + if (idx >= batch.candidates.size) { null } else { - Step(verb, candidate.key, Group.CANDIDATE, candidate.derivation) + val candidate = batch.candidates[idx] + Step(verbs[cursor % verbs.size], candidate.key, Group.CANDIDATE, candidate.derivation) } } } else -> null } + /** + * The VALUE verbs a candidate name is asked through — every one that answered, in a stable order + * (121 before 128) so the plan is deterministic. Empty when neither answered, which retires the sweep. + */ + private fun candidateVerbs(): List { + val verbs = mutableListOf() + if (deviceConfigVerb == VerbStatus.ANSWERED) { + verbs.add(DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD) + } + if (featureFlagVerb == VerbStatus.ANSWERED) { + verbs.add(DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD) + } + return verbs + } + /** * The keys whose values are worth reading because they are already known to exist: the sixteen flags * NOOP writes, then whatever the strap enumerated for itself (capped, and never re-listing a flag). @@ -701,7 +778,9 @@ class DeviceConfigReadProbeReport( if (found.isNotEmpty()) { return "${found.size} config key name(s) found that NOOP did not have: ${found.joinToString(", ")}" } - if (enumerationVerb == VerbStatus.ANSWERED) { + // Only claim "enumeration settled it" when enumeration was in fact the whole run. A forced + // sweep asked dozens of names as well, and its clean negative is the more informative headline. + if (enumerationVerb == VerbStatus.ANSWERED && candidateReadings.isEmpty()) { return "the strap enumerated its device-config namespace and returned no key NOOP did not already have" } val answered = listOf(featureFlagVerb, deviceConfigVerb).count { it == VerbStatus.ANSWERED } @@ -716,13 +795,27 @@ class DeviceConfigReadProbeReport( } return both } - val asked = candidateReadings.size + // Counted in NAMES, not round-trips: each name is asked through every answering verb, so the + // headline would otherwise double. A name only counts as "does not exist" when EVERY verb that + // asked it said so. + val names = candidateReadings.map { it.key }.distinct() + val asked = names.size if (asked == 0) { return "$answered of 2 read verbs answered; no candidate name was asked" } - val unknown = candidateReadings.count { it.existence == ConfigKeySweep.Existence.UNKNOWN } + val unknown = names.count { key -> + val rows = candidateReadings.filter { it.key == key } + rows.isNotEmpty() && rows.all { it.existence == ConfigKeySweep.Existence.UNKNOWN } + } if (unknown == asked) { - return "asked $asked candidate key name(s); this firmware has none of them (a clean negative)" + // A fully-negative sweep is worth more when enumeration ALSO answered: the device-config + // namespace is then fully listed and the guessed names are all refused, which is a much + // stronger negative than a sweep run against a strap that never listed anything. + return if (enumerationVerb == VerbStatus.ANSWERED) { + "asked $asked candidate key name(s); this firmware has none of them, and its device-config namespace enumerated in full (a clean negative)" + } else { + "asked $asked candidate key name(s); this firmware has none of them (a clean negative)" + } } return "asked $asked candidate key name(s); $unknown do not exist, ${asked - unknown} inconclusive" } @@ -835,19 +928,26 @@ class DeviceConfigReadProbeReport( /** The candidate sweep, grouped by derivation, with the tested/untested arithmetic spelled out. */ private fun candidateSection(): String { val rows = candidateReadings - val tested = rows.size + // NAMES, not round-trips: each name is now asked through every answering verb, so counting rows + // would report "108 asked of 54 in the catalogue". The arithmetic has to stay in the same units + // the catalogue is measured in or the untested figure goes negative and stops meaning anything. + val seen = rows.map { it.key }.distinct() + val tested = seen.size val total = ConfigKeySweep.CATALOGUE.size val untested = total - batch.start - tested val sb = StringBuilder() sb.append("\nCandidate key names — GUESSES, never observed on a wire or in any table") + if (forceCandidateSweep) sb.append(" [FULL SWEEP: asked even though enumeration succeeded]") sb.append(" ($tested asked of $total in the catalogue") sb.append(if (untested > 0) "; $untested untested" else "; none untested") sb.append("):\n") if (rows.isEmpty()) { sb.append( when { - _enumeratedKeys.isNotEmpty() -> - " (skipped — the strap enumerated its own device-config keys, so nothing needs guessing)\n" + _enumeratedKeys.isNotEmpty() && !forceCandidateSweep -> + " (skipped — the strap enumerated its own device-config keys, so nothing needs guessing.\n" + + " Enumeration lists what the firmware HOLDS, not everything it would ACCEPT, and says\n" + + " nothing about the feature-flag namespace: re-run with the full name sweep to ask anyway.)\n" featureFlagVerb != VerbStatus.ANSWERED && deviceConfigVerb != VerbStatus.ANSWERED -> " (none — no value verb answered, so no name could be asked)\n" else -> " (none asked)\n" @@ -855,18 +955,32 @@ class DeviceConfigReadProbeReport( ) return sb.toString() } - val exists = rows.count { it.existence == ConfigKeySweep.Existence.EXISTS } - val unknown = rows.count { it.existence == ConfigKeySweep.Existence.UNKNOWN } - sb.append(" $exists exist · $unknown do not · ${tested - exists - unknown} inconclusive\n") + // A NAME exists if ANY verb said so, and is only "does not exist" when EVERY verb that asked said + // so — the whole reason both verbs are asked. + val exists = seen.count { key -> + rows.any { it.key == key && it.existence == ConfigKeySweep.Existence.EXISTS } + } + val unknown = seen.count { key -> + val asked = rows.filter { it.key == key } + asked.isNotEmpty() && asked.all { it.existence == ConfigKeySweep.Existence.UNKNOWN } + } + sb.append(" $exists exist · $unknown do not · ${tested - exists - unknown} inconclusive") + sb.append(" (each name asked through ${candidateVerbs().size} verb(s))\n") for (derivation in ConfigKeySweep.Derivation.entries) { - val group = rows.filter { it.derivation == derivation } - if (group.isEmpty()) continue - sb.append("\n ${derivation.title} (${group.size}):\n") - group.forEachIndexed { i, r -> - sb.append(" %2d. ".format(i + 1)).append(DeviceConfigReadProbe.padded(r.key, 32)) - .append(r.existence.label) - val v = r.value - if (v != null) sb.append(" = ").append(DeviceConfigReadProbe.valueLabel(v)) + val names = seen.filter { key -> rows.any { it.key == key && it.derivation == derivation } } + if (names.isEmpty()) continue + sb.append("\n ${derivation.title} (${names.size}):\n") + names.forEachIndexed { i, key -> + sb.append(" %2d. ".format(i + 1)).append(DeviceConfigReadProbe.padded(key, 32)) + // Per-verb, so a name that answered differently on 121 and 128 is visible rather than + // collapsed into one word. + sb.append( + rows.filter { it.key == key }.joinToString(" · ") { r -> + val v = r.value + "${r.opcode}=${r.existence.label}" + + if (v != null) "(${DeviceConfigReadProbe.valueLabel(v)})" else "" + }, + ) sb.append("\n") } } diff --git a/android/app/src/main/java/com/noop/protocol/DeviceConfigWriteGate.kt b/android/app/src/main/java/com/noop/protocol/DeviceConfigWriteGate.kt new file mode 100644 index 0000000000..8b4451d426 --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/DeviceConfigWriteGate.kt @@ -0,0 +1,379 @@ +package com.noop.protocol + +/** + * #891 / #103: the ONE device-config key this app may write beyond the Broadcast-HR flag — + * `enable_raw_data_w_ecg` — and the key-aware allowlist that keeps every other key off the wire. + * + * ## Where the key came from + * + * The strap listed it itself. `START_DEVICE_CONFIG_KEY_EXCHANGE(115)` + `SEND_NEXT_DEVICE_CONFIG(116)` — + * the read-only enumeration pair [ConfigKeySweep] builds — was answered by a WHOOP 5 MG with + * `revision=1 count=7`, and the walk ran to a clean `index=255 validKey=false` terminator listing: + * + * ``` + * sigproc_wear_detect enable_rfid max_collection_backlog + * cont_collection_mode whoop_live_hr_in_adv_ind_pkt whoop_live_2_hrm_devices + * enable_raw_data_w_ecg + * ``` + * + * Six of those seven were unknown to this codebase. Nothing here is guessed: the names are the strap's + * own, read off its own enumeration. + * + * ## Why this key, and why now + * + * #891 records that all three TOGGLE_LABRADOR (ECG) commands — 139, 125, 124 — answer `SUCCESS(1)` on a + * WHOOP 5 MG and produce **zero** ECG packets in a 30-second listen. What the echoed byte those replies + * carry actually MEANS is still open: arg-echo is refuted (SELECT_WRIST was sent 0 and answered 1) and + * blanket payload-echo is refuted (GET_BATTERY_LEVEL sends `[0x00]` and answers 0x2F), but "read-back of + * stored state" remains an inference — a per-opcode handler echoing a constant fits every observation so + * far just as well. Either way the practical lesson holds: a `SUCCESS` result byte is not evidence that + * state changed. + * + * On the same strap `enable_raw_data_w_ecg` reads `'0'`. A device-config key whose name pairs "raw data" + * with "ecg", sitting at `'0'` on a strap whose ECG toggles accept and emit nothing, is the leading + * candidate for the gate. **Whether flipping it actually produces ECG data is UNKNOWN.** A negative result + * — gate flipped to `'1'`, read back as `'1'`, still no packets — is a publishable answer that removes the + * leading hypothesis from #891, and is the outcome this path is built to establish either way. + * + * ## Why a write is safe to offer at all + * + * Config writes on this firmware are demonstrated, not assumed: running the existing `enable_r22_*` + * sequence (#174) moved `enable_sig12` from `'2'` (0x32) to `'1'` (0x31), confirmed by a 121 read before + * and after. So a `SET_DEVICE_CONFIG_VALUE(119)` write lands, and the value that comes back afterwards is + * real rather than an echo of what was sent. + * + * ## Read-back is the proof, not the ack + * + * The write's own `COMMAND_RESPONSE` is **not** treated as evidence. #891 is the standing example of why: + * `SELECT_WRIST` returns SUCCESS for a no-op and FAILURE for a real change, so a result byte says nothing + * reliable about whether state moved. Every write from this path is therefore followed by a + * `GET_DEVICE_CONFIG_VALUE(121)` read of the same key, and only the value that comes back is reported. + * (Framing owed to @ryanbr on #891.) + * + * ## The allowlist is key-aware, not just opcode-aware + * + * Opcode 119 is shared: the Broadcast-HR flag (#181) writes through it too. An opcode-only allowlist + * therefore cannot express "this key and no other", and before this file the 5/MG send path admitted ANY + * device-config key while the Broadcast-HR opt-in happened to be on. [admitsSend] closes that: it parses + * the key name out of the body and admits exactly two keys, each only while its OWN opt-in is on. The five + * remaining enumerated keys are named in [OUT_OF_SCOPE_KEYS] and are refused unconditionally — their + * effects are unknown and nothing here has any reason to move them. + * + * This is the same discipline [DeviceConfigReadProbe.isReadOnlyOpcode] established for the read probes: a + * single 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. + * + * Pure: no Android BLE, no I/O, no preferences. The caller supplies the two opt-in booleans. Twin of the + * Swift `DeviceConfigWriteGate` — keep them byte-identical. + */ +object DeviceConfigWriteGate { + + // Opcodes --------------------------------------------------------------------------------------- + + /** `SET_DEVICE_CONFIG_VALUE` (119 / 0x77) — the only write verb this gate ever admits. */ + const val SET_DEVICE_CONFIG_VALUE_CMD = 119 + + /** `SET_FF_VALUE` (120 / 0x78) — the FEATURE-FLAG write verb (the R22 sequence, #174). Named here for + * exactly one reason: so [admitsSend] can be proved to refuse it. */ + const val SET_FF_VALUE_CMD = 120 + + /** `GET_DEVICE_CONFIG_VALUE` (121 / 0x79) — the read verb the mandatory read-back uses. */ + const val GET_DEVICE_CONFIG_VALUE_CMD = 121 + + // Keys ------------------------------------------------------------------------------------------ + + /** The key this file exists for. Written ONLY while the ECG-gate opt-in is on AND the strap has + * positively attested itself a WHOOP MG ([Whoop5Variant.isMG]) — a plain 5.0 has no electrodes. */ + const val ECG_RAW_DATA_KEY = "enable_raw_data_w_ecg" + + /** The Broadcast-HR key (#181), hardware-validated. Admitted only while ITS own opt-in is on. */ + const val BROADCAST_HR_KEY = "whoop_live_hr_in_adv_ind_pkt" + + /** The other five keys the strap's 115/116 enumeration listed. Each refused unconditionally: their + * effects are undocumented and unmeasured, and `max_collection_backlog` in particular reads `"0.0"`, + * which is not even a flag. Listed rather than merely omitted so the refusal is testable. */ + val OUT_OF_SCOPE_KEYS: List = listOf( + "sigproc_wear_detect", + "enable_rfid", + "max_collection_backlog", + "cont_collection_mode", + "whoop_live_2_hrm_devices", + ) + + /** Every device-config key the strap enumerated, in the order it served them. Reported in the UI and + * used by tests; never used to build a write. */ + val ENUMERATED_KEYS: List = listOf( + "sigproc_wear_detect", + "enable_rfid", + "max_collection_backlog", + "cont_collection_mode", + BROADCAST_HR_KEY, + "whoop_live_2_hrm_devices", + ECG_RAW_DATA_KEY, + ) + + // Values ---------------------------------------------------------------------------------------- + + /** ASCII `'1'` — the gate on. */ + const val ENABLED_VALUE = 0x31 + + /** ASCII `'0'` — the gate off, and what a subscription-free MG reads today. */ + const val DISABLED_VALUE = 0x30 + + /** The value byte for a requested state. */ + fun value(on: Boolean): Int = if (on) ENABLED_VALUE else DISABLED_VALUE + + /** The value byte rendered as the character the strap stores. */ + fun valueString(on: Boolean): String = if (on) "1" else "0" + + /** Width of the key-name field in a device-config body. */ + const val NAME_FIELD_BYTES = 32 + + // Body parsing ---------------------------------------------------------------------------------- + + /** + * The key name carried by a `SET_DEVICE_CONFIG_VALUE` payload, or null when the payload is not shaped + * like one. + * + * The payload the send path holds is `[0x01] + deviceConfigBody(...)`: the b3 byte, then the 32-byte + * NUL-padded name, then the value. 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 null and is refused rather than guessed at. + */ + fun keyNameInSendPayload(payload: ByteArray): String? { + if (payload.size < 1 + NAME_FIELD_BYTES) return null + if ((payload[0].toInt() and 0xFF) != 0x01) return null + val name = StringBuilder() + var terminated = false + for (i in 0 until NAME_FIELD_BYTES) { + val b = payload[i + 1].toInt() and 0xFF + if (b == 0) { terminated = true; break } + if (b < 0x20 || b > 0x7E) return null + name.append(b.toChar()) + } + if (name.isEmpty()) return null + // Everything after the name must be NUL, or this is not a NUL-padded name field. + if (terminated) { + for (i in name.length until NAME_FIELD_BYTES) { + if ((payload[i + 1].toInt() and 0xFF) != 0) return null + } + } + return name.toString() + } + + // The allowlist predicate ----------------------------------------------------------------------- + + /** + * Whether a device-config KEY may be written, given the two opt-ins and the hardware attestation. + * + * `false` for every key that is not one of the two named ones — including all five of + * [OUT_OF_SCOPE_KEYS], and including any key a future edit invents. The ECG key additionally requires + * [isMG]: `Whoop5Variant.UNKNOWN` is not MG, so an unattested strap fails this the same way a plain + * 5.0 does. + */ + fun isWritableKey( + key: String, + ecgGateOptIn: Boolean, + isMG: Boolean, + broadcastHrOptIn: Boolean, + ): Boolean = when (key) { + ECG_RAW_DATA_KEY -> ecgGateOptIn && isMG + BROADCAST_HR_KEY -> broadcastHrOptIn + else -> false + } + + /** + * **The send allowlist itself.** True only for `SET_DEVICE_CONFIG_VALUE(119)` carrying a well-formed + * body whose key passes [isWritableKey]. + * + * Every other opcode is false — explicitly including `SET_FF_VALUE(120)`, which the R22 sequence keeps + * its own separate clause for and which must never be reachable from here. + */ + fun admitsSend( + opcode: Int, + payload: ByteArray, + ecgGateOptIn: Boolean, + isMG: Boolean, + broadcastHrOptIn: Boolean, + ): Boolean { + if (opcode != SET_DEVICE_CONFIG_VALUE_CMD) return false + val key = keyNameInSendPayload(payload) ?: return false + return isWritableKey(key, ecgGateOptIn, isMG, broadcastHrOptIn) + } + + /** The read verb the post-write verification is allowed to send, and only that one. */ + fun isReadBackOpcode(opcode: Int): Boolean = opcode == GET_DEVICE_CONFIG_VALUE_CMD + + // Frames ---------------------------------------------------------------------------------------- + + /** + * The `SET_DEVICE_CONFIG_VALUE(119)` payload that sets the ECG gate. + * + * Deliberately the SAME 33-byte single-value body the Broadcast-HR write has used on real hardware + * since #181 — `[0x01] + [name NUL-padded to 32][value]`. The strap serves multi-character values + * (`max_collection_backlog` reads `"0.0"`), so the READ side must handle them; but this key's observed + * value is a single ASCII digit and a one-character write is the shape hardware has already accepted. + */ + fun writePayload(on: Boolean): ByteArray = + byteArrayOf(0x01) + Whoop5Config.deviceConfigBody(ECG_RAW_DATA_KEY, value(on)) + + /** The `GET_DEVICE_CONFIG_VALUE(121)` payload that reads the ECG gate back. */ + fun readBackPayload(): ByteArray = DeviceConfigReadProbe.requestBody(ECG_RAW_DATA_KEY) +} + +/** + * The result of one ECG-gate write + mandatory read-back, as a copyable report. + * + * Order-dependent and pure (noteWriteAck → noteReadBack/noteReadBackTimeout → render), so the unit tests + * cover the whole verdict table without a strap. Twin of the Swift `EcgRawDataGateReport`; [render] is + * byte-identical across platforms so a shared strap log reads the same either side. + */ +class EcgRawDataGateReport(on: Boolean) { + + /** What the run established. Deliberately blunt about the case that matters: a write whose ack said + * SUCCESS but whose read-back did not move is [UNCHANGED], not success. */ + enum class Verdict(val label: String) { + /** Read-back returned exactly the value that was requested. The only success case. */ + CONFIRMED("confirmed"), + + /** Read-back returned a DIFFERENT value than requested — the write did not take. */ + UNCHANGED("unchanged"), + + /** The strap answered the read-back but did not echo the key, so no value can be claimed. */ + NOT_CLAIMED("notClaimed"), + + /** The strap refused the read-back verb, or answered FAILURE for the key. */ + REFUSED("refused"), + + /** No reply to the read-back inside its window. */ + SILENT("silent"), + + /** A reply arrived that could not be decoded (CRC, envelope, short record). */ + UNDECODABLE("undecodable"), + + /** The read-back has not resolved yet. */ + PENDING("pending"), + } + + /** The value that was requested, as the strap stores it ("1" or "0"). */ + val requested: String = DeviceConfigWriteGate.valueString(on) + + /** Result code of the WRITE's own COMMAND_RESPONSE, when one arrived. Recorded, never trusted. */ + var writeResultCode: Int? = null + private set + + /** The value the read-back actually returned. null means "no value claimed", never "zero". */ + var storedValue: String? = null + private set + + /** Result code of the READ-BACK's COMMAND_RESPONSE. */ + var readBackResultCode: Int? = null + private set + + /** Raw read-back record bytes as hex, always reported whatever else decodes. */ + var readBackRecordHex: String? = null + private set + + private val _trace = mutableListOf() + + /** Trace lines, one per round-trip. */ + val trace: List get() = _trace + + /** The verdict so far. */ + var verdict: Verdict = Verdict.PENDING + private set + + init { + _trace.add( + "SET_DEVICE_CONFIG_VALUE(119) key=\"${DeviceConfigWriteGate.ECG_RAW_DATA_KEY}\" " + + "value='$requested' sent", + ) + } + + /** Record the write's own ack. It is logged and NOT used to decide anything: #891 established that a + * SUCCESS result code does not prove state changed. */ + fun noteWriteAck(resultCode: Int?) { + writeResultCode = resultCode + val label = resultCode?.let { "${FeatureFlagProbe.resultLabel(it)}($it)" } ?: "(unlabelled)" + _trace.add( + "write ack → result=$label — recorded, not treated as proof; the read-back below is the proof", + ) + } + + /** Record the decoded read-back and reach a verdict. */ + fun noteReadBack(r: DeviceConfigReadProbe.ValueResponse) { + readBackResultCode = r.resultCode + readBackRecordHex = r.recordHex + val stored = r.stringValueFor(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + storedValue = stored + val sb = StringBuilder( + "GET_DEVICE_CONFIG_VALUE(121) key=\"${DeviceConfigWriteGate.ECG_RAW_DATA_KEY}\"", + ) + val c = r.resultCode + if (c != null) sb.append(" → result=${FeatureFlagProbe.resultLabel(c)}($c)") else sb.append(" →") + if (stored != null) sb.append(" value='$stored'") + sb.append(" record=[${r.recordHex}]") + _trace.add(sb.toString()) + + verdict = when { + r.isUnsupported || r.isFailure -> Verdict.REFUSED + stored == null -> Verdict.NOT_CLAIMED + stored == requested -> Verdict.CONFIRMED + else -> Verdict.UNCHANGED + } + } + + /** Record a read-back reply that could not be decoded. */ + fun noteReadBackFailure(f: DeviceConfigReadProbe.ParseFailure) { + 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("read-back reply not decoded: $why") + verdict = Verdict.UNDECODABLE + } + + /** Record the strap answering nothing at all to the read-back. */ + fun noteReadBackTimeout(seconds: Int) { + _trace.add("GET_DEVICE_CONFIG_VALUE(121) → no COMMAND_RESPONSE within ${seconds}s") + verdict = Verdict.SILENT + } + + /** One-line summary, suitable for a Settings row. */ + val summary: String + get() = when (verdict) { + Verdict.CONFIRMED -> + "Strap now reports ${DeviceConfigWriteGate.ECG_RAW_DATA_KEY}='$requested' " + + "(read back, not just acked)." + Verdict.UNCHANGED -> + "Write did NOT take: asked for '$requested', strap still reports '${storedValue ?: "?"}'." + Verdict.NOT_CLAIMED -> + "Strap answered the read-back but did not echo the key, so no value is claimed." + Verdict.REFUSED -> + "Strap refused the read-back for this key — the stored value is unknown." + Verdict.SILENT -> "No reply to the read-back — the stored value is unknown." + Verdict.UNDECODABLE -> + "The read-back reply did not decode — the stored value is unknown." + Verdict.PENDING -> "Waiting for the read-back…" + } + + /** The full copyable report. */ + fun render(): String { + val sb = StringBuilder() + sb.append("#891 ECG RAW-DATA GATE — WHOOP MG\n") + sb.append("Key: ${DeviceConfigWriteGate.ECG_RAW_DATA_KEY} ") + sb.append("(the strap's own 115/116 enumeration listed it)\n") + sb.append("Wrote '$requested' via SET_DEVICE_CONFIG_VALUE(119), then read it back with ") + sb.append("GET_DEVICE_CONFIG_VALUE(121). SET_FF_VALUE(120) is never sent from this path, and no ") + sb.append("other device-config key is writable from it.\n") + sb.append("\nVerdict: ${verdict.label} — $summary\n") + sb.append("\nExchange:\n") + for (line in _trace) sb.append(" ").append(line).append("\n") + sb.append("\nWhether this gate actually produces ECG data is UNKNOWN. If it now reads '1' and a ") + sb.append("TOGGLE_LABRADOR listen still yields zero packets, that is a real result for #891 — ") + sb.append("please share this report there either way.\n") + return sb.toString() + } +} 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 056d81a8cb..d3a70b94d2 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -109,6 +109,8 @@ import com.noop.ble.WhoopModel import com.noop.data.DataBackup import com.noop.ingest.RawSensorExport import com.noop.ingest.WhoopCsvExporter +// #891: the ECG-gate row reports the strap's READ-BACK verdict, never the write's own ack. +import com.noop.protocol.EcgRawDataGateReport import com.noop.update.UpdateCheck import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -466,6 +468,14 @@ fun SettingsScreen( var puffinCapture by remember(rev) { mutableStateOf(puffinExperiment.isCaptureEnabled) } var deepData by remember(rev) { mutableStateOf(puffinExperiment.isDeepDataEnabled) } var broadcastHr by remember(rev) { mutableStateOf(puffinExperiment.broadcastHr) } + // #891: the ECG raw-data gate opt-in — a PERSISTENT strap write, so its own switch (like #174/#181). + var ecgRawData by remember(rev) { mutableStateOf(puffinExperiment.ecgRawData) } + // #891: the last write's READ-BACK verdict, and the strap's attested hardware variant. `.UNKNOWN` is + // not MG, so the buttons stay disabled until the hardware itself says otherwise — a plain 5.0 has no + // ECG electrodes. + val ecgGateReport by vm.ble.ecgRawDataGate.collectAsStateWithLifecycle() + val ecgVariant by vm.ble.whoop5VariantFlow.collectAsStateWithLifecycle() + val ecgVariantIsMG = ecgVariant.isMG // "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). var experimentalSleepV2 by remember { mutableStateOf(puffinExperiment.experimentalSleepV2) } @@ -1852,6 +1862,79 @@ fun SettingsScreen( color = Palette.textTertiary, ) + // --- #891 ECG raw-data gate — the second key this app may write, and MG-only. --- + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + uiString(R.string.l10n_settings_screen_ecg_raw_data_gate_whoop_mg_only), + style = NoopType.subhead, + color = Palette.textPrimary, + modifier = Modifier.weight(1f), + ) + Switch( + checked = ecgRawData, + onCheckedChange = { + ecgRawData = it + puffinExperiment.ecgRawData = it + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Palette.surfaceBase, + checkedTrackColor = Palette.accent, + uncheckedThumbColor = Palette.textSecondary, + uncheckedTrackColor = Palette.surfaceInset, + uncheckedBorderColor = Palette.hairline, + ), + modifier = Modifier.semantics { + contentDescription = + uiString(R.string.l10n_settings_screen_ecg_raw_data_gate_whoop_mg_only) + }, + ) + } + Text( + uiString(R.string.l10n_settings_screen_ecg_gate_blurb), + style = NoopType.caption, + color = Palette.textTertiary, + ) + if (ecgRawData) { + Text( + uiString(R.string.l10n_settings_screen_ecg_gate_persistent_warning), + style = NoopType.caption, + color = Palette.statusWarning, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + NoopButton( + text = uiString(R.string.l10n_settings_screen_ecg_gate_turn_on), + kind = NoopButtonKind.Primary, + // Same encrypted-bond requirement the R22 writes have, plus a strap that has + // positively attested itself an MG. Not wear-gated: this stores a value. + enabled = live.bonded && ecgVariantIsMG, + onClick = { vm.ble.setEcgRawDataGate(true) }, + ) + NoopButton( + text = uiString(R.string.l10n_settings_screen_ecg_gate_turn_off), + kind = NoopButtonKind.Secondary, + enabled = live.bonded && ecgVariantIsMG, + onClick = { vm.ble.setEcgRawDataGate(false) }, + ) + } + // The read-back — the ONLY thing reported as a result. The write's own ack goes to the + // strap log for the record and is deliberately not surfaced as an outcome here. + ecgGateReport?.let { report -> + Text( + report.summary, + style = NoopType.caption, + color = if (report.verdict == EcgRawDataGateReport.Verdict.CONFIRMED) { + Palette.statusPositive + } else { + Palette.textSecondary + }, + ) + } + } + // --- R22 deep-data unlock — the one probe that writes to the strap. (#174) --- Row( modifier = Modifier.fillMaxWidth(), diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 355accd313..67ff0371d4 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1771,4 +1771,9 @@ Dein Smart-Wecker hat gerade geklingelt. Akku-Hinweise Hinweise, wenn der Strap-Akku schwach oder voll geladen ist. + ECG-Rohdatensperre (nur WHOOP MG) + Dein Band hat seine eigenen Geräte-Konfigurationsschlüssel aufgelistet, darunter enable_raw_data_w_ecg. Auf einem MG ohne ECG-Abo steht er auf \'0\' — während alle drei ECG-Befehle mit SUCCESS antworten und trotzdem keine Daten senden (#891). Das ist die naheliegendste Vermutung, was ECG verschlossen hält. Ob das Umlegen tatsächlich ECG-Daten liefert, weiß niemand: genau das herauszufinden ist der Zweck, und „immer noch nichts“ ist ein brauchbares Ergebnis, das sich zu posten lohnt (#891). + Dies schreibt eine Einstellung, die AUF DEINEM BAND BLEIBT, bis du sie zurücksetzt — es ist keine App-Einstellung, und das Schließen von NOOP macht sie nicht rückgängig. „Sperre ausschalten“ schreibt mit einem Tippen wieder \'0\'. Es wird ausschließlich dieser eine Schlüssel geschrieben; die anderen sechs, die dein Band aufgelistet hat, werden nie angefasst. + Sperre einschalten (\'1\' schreiben) + Sperre ausschalten (\'0\' schreiben) diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 89654de227..46747faf50 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1756,4 +1756,9 @@ Tu alarma inteligente acaba de sonar. Alertas de batería Avisos cuando la batería de la pulsera está baja o completamente cargada. + Bloqueo de datos ECG sin procesar (solo WHOOP MG) + Tu correa ha enumerado sus propias claves de configuración, y una de ellas es enable_raw_data_w_ecg. En un MG sin suscripción de ECG vale \'0\', mientras que los tres comandos de ECG responden SUCCESS y no envían dato alguno (#891). Es la hipótesis principal sobre qué mantiene el ECG cerrado. Nadie sabe si cambiarla produce realmente datos de ECG: averiguarlo es el objetivo, y «sigue sin haber nada» es una respuesta útil que merece publicarse en #891. + Esto escribe un ajuste que PERMANECE EN TU CORREA hasta que lo cambies de vuelta: no es una preferencia de la app y cerrar NOOP no lo deshace. «Desactivar bloqueo» vuelve a escribir \'0\' con un solo toque. Solo se escribe esta clave; las otras seis que enumeró tu correa nunca se tocan. + Activar bloqueo (escribir \'1\') + Desactivar bloqueo (escribir \'0\') diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 90d8d4cc48..9211d5bd59 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1756,4 +1756,9 @@ Votre alarme intelligente vient de sonner. Alertes de batterie Alertes lorsque la batterie du bracelet est faible ou complètement chargée. + Verrou des données ECG brutes (WHOOP MG uniquement) + Votre bracelet a énuméré ses propres clés de configuration, dont enable_raw_data_w_ecg. Sur un MG sans abonnement ECG, elle vaut « 0 » — alors que les trois commandes ECG répondent SUCCESS sans envoyer la moindre donnée (#891). C\'est l\'hypothèse principale sur ce qui maintient l\'ECG fermé. Personne ne sait si la basculer produit réellement des données ECG : le savoir est justement le but, et « toujours rien » est une réponse utile à publier sur #891. + Ceci écrit un réglage qui RESTE SUR VOTRE BRACELET jusqu\'à ce que vous le remettiez — ce n\'est pas une préférence de l\'app, et fermer NOOP ne l\'annule pas. « Désactiver le verrou » réécrit « 0 » en une seule touche. Seule cette clé est écrite ; les six autres que votre bracelet a énumérées ne sont jamais touchées. + Activer le verrou (écrire « 1 ») + Désactiver le verrou (écrire « 0 ») 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 d6fe43a050..a4dad52878 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -1750,4 +1750,9 @@ O teu alarme inteligente acabou de tocar. Alertas de bateria Avisos quando a bateria da correia está fraca ou totalmente carregada. + Bloqueio de dados ECG em bruto (apenas WHOOP MG) + A sua pulseira listou as suas próprias chaves de configuração, e uma delas é enable_raw_data_w_ecg. Num MG sem subscrição de ECG lê \'0\' — enquanto os três comandos de ECG respondem SUCCESS e não enviam dados nenhuns (#891). Esta é a principal hipótese para o que mantém o ECG fechado. Ninguém sabe se mudá-la produz mesmo dados de ECG: descobrir é o objetivo, e «continua sem nada» é uma resposta útil que vale a pena publicar em #891. + Isto escreve uma definição que FICA NA SUA PULSEIRA até a repor — não é uma preferência da aplicação e fechar o NOOP não a desfaz. «Desativar bloqueio» volta a escrever \'0\' com um só toque. Só esta chave é escrita; as outras seis que a sua pulseira listou nunca são tocadas. + Ativar bloqueio (escrever \'1\') + Desativar bloqueio (escrever \'0\') diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 6e61e78fef..f5541c8f25 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1684,4 +1684,9 @@ 你的智能闹钟刚刚响了。 电量提醒 当手环电量低或已充满时提醒。 + ECG 原始数据开关(仅限 WHOOP MG) + 你的手环列出了它自己的设备配置键,其中之一是 enable_raw_data_w_ecg。在没有 ECG 订阅的 MG 上它读作 \'0\',而三条 ECG 命令都回应 SUCCESS 却完全不发送数据 (#891)。这是目前对「什么在挡住 ECG」最有力的猜测。翻转它是否真能产生 ECG 数据,没有人知道:弄清楚正是目的,而「仍然没有」同样是值得发到 #891 的有用答案。 + 这会写入一项会一直留在你手环上的设置,直到你改回来——它不是应用内的偏好设置,关闭 NOOP 也不会撤销。「关闭开关」一键即可再写入 \'0\'。全程只写入这一个键;你手环列出的另外六个从不触碰。 + 打开开关(写入 \'1\') + 关闭开关(写入 \'0\') diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f8e710b888..2fd7e99a3b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1782,4 +1782,9 @@ Your smart alarm just went off. Battery alerts Alerts when the strap battery is low or fully charged. + ECG raw-data gate (WHOOP MG only) + Your strap listed its own device-config keys, and one of them is enable_raw_data_w_ecg. On an MG with no ECG subscription it reads \'0\' — while all three ECG commands answer SUCCESS and send no data at all (#891). This is the leading guess for what\'s holding ECG shut. Nobody knows whether flipping it actually produces ECG data: finding out is the point, and \"still nothing\" is a useful answer worth posting to #891. + This writes a setting that STAYS ON YOUR STRAP until you change it back — it isn\'t an app preference, and closing NOOP won\'t undo it. \"Turn gate off\" writes \'0\' again, in one tap. Only this one key is ever written; the other six your strap listed are never touched. + Turn gate on (write \'1\') + Turn gate off (write \'0\') diff --git a/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt b/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt index f5d5333b8d..84e719a2e1 100644 --- a/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt +++ b/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt @@ -323,6 +323,69 @@ class DeviceConfigReadProbeTest { assertNull(first.derivation) } + /** A successful enumeration is evidence about what the firmware HOLDS, not proof of what it would + * ACCEPT — and it says nothing at all about the feature-flag namespace, where most of the catalogue + * is aimed. So a run can be asked to sweep anyway, and then the candidate steps must actually happen. */ + @Test + fun aForcedSweepAsksTheCandidatesEvenAfterEnumerationSucceeds() { + val report = DeviceConfigReadProbeReport( + DeviceFamily.WHOOP5, + listOf("enable_r22_packets", "hr_ch_switching"), + ConfigKeySweep.batch(0, 2), + forceCandidateSweep = true, + ) + assertTrue(report.runsCandidateSweep) + + report.nextStep()!! + report.noteEnumerationStart(startReply(enumStart(1, 10, 1))) + report.nextStep()!! + // The Broadcast-HR key: enumerated, and one NOOP already had — so newKeysFound stays empty and the + // headline is free to report what the sweep established rather than what enumeration found. + assertTrue(report.noteEnumerationNext(nextReply(enumNext(1, "whoop_live_hr_in_adv_ind_pkt")))) + report.nextStep()!! + assertFalse(report.noteEnumerationNext(nextReply(enumNext(0xFF, null, validKey = false)))) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.ANSWERED, report.enumerationVerb) + assertTrue(report.enumeratedKeys.isNotEmpty()) + + val candidates = mutableListOf() + var guard = 0 + while (guard < 200) { + val step = report.nextStep() ?: break + guard += 1 + val isCandidate = step.group == DeviceConfigReadProbeReport.Group.CANDIDATE + if (isCandidate) candidates.add(step.key) + report.noteReply(valueReply(if (isCandidate) 0 else 1, echoRecord(step.key, 0x30)), step) + } + assertEquals( + ConfigKeySweep.batch(0, 2).candidates.map { it.key }, + candidates.distinct(), + ) + val text = report.render() + assertTrue(text.contains("FULL SWEEP: asked even though enumeration succeeded")) + assertFalse(text.contains("skipped — the strap enumerated its own device-config keys")) + assertTrue(report.verdict, report.verdict.contains("clean negative")) + assertTrue(report.verdict, report.verdict.contains("enumerated in full")) + } + + /** The routing fix itself: every candidate is asked through BOTH value verbs when both answered. */ + @Test + fun everyCandidateIsAskedThroughEveryAnsweringVerb() { + val (report, first) = driveToCandidates(2) + var step = first + val byKey = mutableMapOf>() + while (step != null) { + byKey.getOrPut(step.key) { mutableListOf() }.add(step.opcode) + report.noteReply(valueReply(0, ByteArray(0)), step) + step = report.nextStep() + } + for ((key, opcodes) in byKey) { + assertEquals("$key must be asked through both verbs", setOf(121, 128), opcodes.toSet()) + } + val text = report.render() + assertTrue(text, text.contains("121=unknown · 128=unknown")) + assertTrue(text, text.contains("each name asked through 2 verb(s)")) + } + /** If the strap lists its own device-config keys there is nothing left to guess, so the sweep is * skipped entirely rather than spending round-trips on names the answer already covers. */ @Test @@ -507,7 +570,9 @@ class DeviceConfigReadProbeTest { report.noteReply(valueReply(0, ByteArray(0)), step) step = report.nextStep() } - assertEquals(listOf("enable_sig1", "enable_sig2"), asked) + // Each NAME is asked through every verb that answered — here both, so each appears twice. + assertEquals(listOf("enable_sig1", "enable_sig1", "enable_sig2", "enable_sig2"), asked) + assertEquals(2, asked.distinct().size) assertEquals( "asked 2 candidate key name(s); this firmware has none of them (a clean negative)", report.verdict, @@ -560,7 +625,8 @@ class DeviceConfigReadProbeTest { if (isCandidate) candidates += 1 report.noteReply(valueReply(if (isCandidate) 0 else 1, echoRecord(step.key, 0x32)), step) } - assertEquals(ConfigKeySweep.CATALOGUE.size, candidates) + // One round-trip per (name x answering verb) — both verbs answered here. + assertEquals(ConfigKeySweep.CATALOGUE.size * 2, candidates) assertNull("a full default run must not hit the safety cap", report.stopReason) assertTrue(report.render().contains("none untested")) } @@ -609,10 +675,12 @@ class DeviceConfigReadProbeTest { report.noteReply(valueReply(0, byteArrayOf(0x01, 0x00)), s5) val s6 = report.nextStep()!! report.noteReply(valueReply(1, echoRecord("hr_ch_switching", 0x32)), s6) - val c1 = report.nextStep()!! - report.noteReply(valueReply(0, byteArrayOf(0x01, 0x00)), c1) - val c2 = report.nextStep()!! - report.noteReply(valueReply(0, byteArrayOf(0x01, 0x00)), c2) + // Two names × two answering verbs = four candidate round-trips. + repeat(4) { + val c = report.nextStep()!! + assertEquals(DeviceConfigReadProbeReport.Group.CANDIDATE, c.group) + report.noteReply(valueReply(0, byteArrayOf(0x01, 0x00)), c) + } assertNull(report.nextStep()) assertEquals(GOLDEN_REPORT, report.render()) @@ -647,11 +715,11 @@ Known key values (the flags NOOP writes, plus anything enumeration returned) (1) 1. hr_ch_switching = '2' (0x32) Candidate key names — GUESSES, never observed on a wire or in any table (2 asked of 54 in the catalogue; 52 untested): - 0 exist · 2 do not · 0 inconclusive + 0 exist · 2 do not · 0 inconclusive (each name asked through 2 verb(s)) sig series (T8) — the firmware numbers its signal chains; sig11/sig12 are the two we have (2): - 1. enable_sig1 unknown - 2. enable_sig2 unknown + 1. enable_sig1 121=unknown · 128=unknown + 2. enable_sig2 121=unknown · 128=unknown Run the probe again to continue from catalogue entry 3. diff --git a/android/app/src/test/java/com/noop/protocol/DeviceConfigWriteGateTest.kt b/android/app/src/test/java/com/noop/protocol/DeviceConfigWriteGateTest.kt new file mode 100644 index 0000000000..a4e632fcf5 --- /dev/null +++ b/android/app/src/test/java/com/noop/protocol/DeviceConfigWriteGateTest.kt @@ -0,0 +1,338 @@ +package com.noop.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * #891: byte-parity twin of the Swift `DeviceConfigWriteGateTests` — the ECG raw-data gate's write + * allowlist, the body it builds, and the mandatory read-back. + * + * The allowlist tests are the point of this file. [DeviceConfigWriteGate.admitsSend] is the SAME predicate + * the 5/MG send path consults, so proving here that it refuses an opcode or a key is proving it about the + * real wire path rather than about a copy of the rule. + */ +class DeviceConfigWriteGateTest { + + // Helpers --------------------------------------------------------------------------------------- + + /** The payload the send path would hold for a device-config write of [key]. */ + private fun payload(key: String, value: Int = 0x31): ByteArray = + byteArrayOf(0x01) + Whoop5Config.deviceConfigBody(key, value) + + /** A real 5/MG COMMAND_RESPONSE frame carrying [record] for command 121, CRC16 header + CRC32 body, + * so [DeviceConfigReadProbe.parse] runs its CRC gate on these fixtures rather than being bypassed. */ + private fun readBackFrame(record: ByteArray, cmd: Int = 121, result: Int = 1): ByteArray { + var inner = byteArrayOf(36, 1, cmd.toByte(), 0x0A, result.toByte()) + record + val pad = (4 - inner.size % 4) % 4 + if (pad > 0) inner += ByteArray(pad) + val declLen = inner.size + 4 + val head = byteArrayOf( + 0xAA.toByte(), 0x01, (declLen and 0xFF).toByte(), ((declLen shr 8) and 0xFF).toByte(), + 0x00, 0x01, + ) + val c16 = Crc.crc16Modbus(head) + val c32 = Crc.crc32(inner) + return head + byteArrayOf((c16 and 0xFF).toByte(), ((c16 shr 8) and 0xFF).toByte()) + inner + + byteArrayOf( + (c32 and 0xFFL).toByte(), ((c32 shr 8) and 0xFFL).toByte(), + ((c32 shr 16) and 0xFFL).toByte(), ((c32 shr 24) and 0xFFL).toByte(), + ) + } + + /** A 121 reply record echoing [key] in a 32-byte NUL-padded field, then [value] as ASCII. */ + private fun echoRecord(key: String, value: String): ByteArray { + val field = ByteArray(DeviceConfigWriteGate.NAME_FIELD_BYTES) + val bytes = key.toByteArray(Charsets.UTF_8) + for (i in 0 until minOf(field.size, bytes.size)) field[i] = bytes[i] + return field + value.toByteArray(Charsets.US_ASCII) + } + + private fun parseReadBack(frame: ByteArray): DeviceConfigReadProbe.ValueResponse { + val parsed = DeviceConfigReadProbe.parse(frame, DeviceFamily.WHOOP5, 121) + return requireNotNull(parsed.value) { "read-back frame should parse" } + } + + // The allowlist: what it admits ------------------------------------------------------------------ + + @Test + fun admitsEcgKeyOnlyWhenOptedInOnAnAttestedMG() { + val p = payload(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + assertTrue(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = true, isMG = true, broadcastHrOptIn = false)) + assertFalse(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = false, isMG = true, broadcastHrOptIn = false)) + assertFalse(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = true, isMG = false, broadcastHrOptIn = false)) + // The Broadcast-HR opt-in must NOT carry the ECG key — the hole the key-aware gate closes. + assertFalse(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = false, isMG = true, broadcastHrOptIn = true)) + } + + @Test + fun admitsBroadcastHrKeyOnlyUnderItsOwnOptIn() { + val p = payload(DeviceConfigWriteGate.BROADCAST_HR_KEY) + assertTrue(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = false, isMG = false, broadcastHrOptIn = true)) + assertFalse(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = false, isMG = false, broadcastHrOptIn = false)) + assertFalse(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = true, isMG = true, broadcastHrOptIn = false)) + // Broadcast HR is not MG-gated: it works on a plain 5.0, and must keep doing so. + assertTrue(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = true, isMG = false, broadcastHrOptIn = true)) + } + + // The allowlist: what it refuses ----------------------------------------------------------------- + + @Test + fun refusesEveryOtherEnumeratedKeyEvenWithBothOptInsOn() { + for (key in DeviceConfigWriteGate.OUT_OF_SCOPE_KEYS) { + assertFalse( + "$key must never be writable from this path", + DeviceConfigWriteGate.admitsSend( + 119, payload(key), ecgGateOptIn = true, isMG = true, broadcastHrOptIn = true, + ), + ) + } + assertEquals(5, DeviceConfigWriteGate.OUT_OF_SCOPE_KEYS.size) + assertEquals( + ( + DeviceConfigWriteGate.OUT_OF_SCOPE_KEYS + + listOf(DeviceConfigWriteGate.ECG_RAW_DATA_KEY, DeviceConfigWriteGate.BROADCAST_HR_KEY) + ).toSet(), + DeviceConfigWriteGate.ENUMERATED_KEYS.toSet(), + ) + assertEquals(7, DeviceConfigWriteGate.ENUMERATED_KEYS.size) + } + + @Test + fun refusesSetFeatureFlagValue120ForEveryKeyAndEveryOptIn() { + val keys = DeviceConfigWriteGate.ENUMERATED_KEYS + Whoop5Config.enableR22Sequence.map { it.name } + for (key in keys) { + for (ecg in listOf(true, false)) { + for (hr in listOf(true, false)) { + assertFalse( + "SET_FF_VALUE(120) must never be admitted (key=$key)", + DeviceConfigWriteGate.admitsSend( + DeviceConfigWriteGate.SET_FF_VALUE_CMD, payload(key), + ecgGateOptIn = ecg, isMG = true, broadcastHrOptIn = hr, + ), + ) + } + } + } + } + + @Test + fun refusesEveryOtherOpcodeInTheWholeByteRange() { + val p = payload(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + val admitted = (0..255).filter { + DeviceConfigWriteGate.admitsSend(it, p, ecgGateOptIn = true, isMG = true, broadcastHrOptIn = true) + } + assertEquals(listOf(DeviceConfigWriteGate.SET_DEVICE_CONFIG_VALUE_CMD), admitted) + } + + @Test + fun refusesUnknownAndMalformedKeys() { + for ((ecg, hr) in listOf(true to true, true to false, false to true, false to false)) { + assertFalse( + DeviceConfigWriteGate.admitsSend( + 119, payload("enable_something_invented"), ecgGateOptIn = ecg, isMG = true, broadcastHrOptIn = hr, + ), + ) + for (near in listOf("enable_raw_data_w_ec", "enable_raw_data_w_ecg2", "ENABLE_RAW_DATA_W_ECG")) { + assertFalse( + "$near must not pass", + DeviceConfigWriteGate.admitsSend( + 119, payload(near), ecgGateOptIn = ecg, isMG = true, broadcastHrOptIn = hr, + ), + ) + } + } + val good = payload(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + val malformed = listOf( + ByteArray(0), + byteArrayOf(0x01), + good.copyOfRange(1, good.size), + byteArrayOf(0x02) + good.copyOfRange(1, good.size), + good.copyOfRange(0, 20), + ) + for (bad in malformed) { + assertFalse( + DeviceConfigWriteGate.admitsSend(119, bad, ecgGateOptIn = true, isMG = true, broadcastHrOptIn = true), + ) + } + } + + @Test + fun keyNameParsingRejectsNonNulPaddingAndNonAscii() { + // Junk AFTER the NUL terminator is not a NUL-padded name field — nothing is claimed. + val body = payload(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + body[1 + DeviceConfigWriteGate.ECG_RAW_DATA_KEY.length + 2] = 0x41 + assertNull(DeviceConfigWriteGate.keyNameInSendPayload(body)) + assertFalse( + DeviceConfigWriteGate.admitsSend(119, body, ecgGateOptIn = true, isMG = true, broadcastHrOptIn = true), + ) + // Extending the name itself yields a DIFFERENT name, which the key allowlist then refuses. + val extended = payload(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + extended[1 + DeviceConfigWriteGate.ECG_RAW_DATA_KEY.length] = 0x41 + assertEquals("enable_raw_data_w_ecgA", DeviceConfigWriteGate.keyNameInSendPayload(extended)) + assertFalse( + DeviceConfigWriteGate.admitsSend(119, extended, ecgGateOptIn = true, isMG = true, broadcastHrOptIn = true), + ) + // Binary in the name field is refused, not transliterated. + val binary = payload(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + binary[3] = 0xFF.toByte() + assertNull(DeviceConfigWriteGate.keyNameInSendPayload(binary)) + assertEquals("enable_rfid", DeviceConfigWriteGate.keyNameInSendPayload(payload("enable_rfid"))) + } + + @Test + fun readBackOpcodeIsOnly121() { + assertEquals(listOf(121), (0..255).filter { DeviceConfigWriteGate.isReadBackOpcode(it) }) + assertFalse(DeviceConfigWriteGate.isReadBackOpcode(119)) + assertFalse(DeviceConfigWriteGate.isReadBackOpcode(120)) + assertFalse(DeviceConfigWriteGate.isReadBackOpcode(128)) + } + + // The bytes on the wire -------------------------------------------------------------------------- + + @Test + fun writePayloadIsTheHardwareValidatedBroadcastHrShape() { + val on = DeviceConfigWriteGate.writePayload(true) + assertEquals(1 + 32 + 1, on.size) + assertEquals(0x01, on[0].toInt()) + assertEquals("enable_raw_data_w_ecg", DeviceConfigWriteGate.keyNameInSendPayload(on)) + assertEquals(0x31, on.last().toInt() and 0xFF) + val off = DeviceConfigWriteGate.writePayload(false) + assertEquals(0x30, off.last().toInt() and 0xFF) + // Both directions differ ONLY in the value byte — reversibility is one byte, not a second path. + assertTrue(on.copyOfRange(0, on.size - 1).contentEquals(off.copyOfRange(0, off.size - 1))) + } + + @Test + fun readBackPayloadMatchesTheReadProbesRequestShape() { + assertTrue( + DeviceConfigWriteGate.readBackPayload() + .contentEquals(DeviceConfigReadProbe.requestBody("enable_raw_data_w_ecg")), + ) + } + + // Multi-character values (max_collection_backlog = "0.0") ---------------------------------------- + + @Test + fun stringValueReadsMultiCharacterValues() { + val r = DeviceConfigReadProbe.ValueResponse(1, echoRecord("max_collection_backlog", "0.0")) + // The single-byte reader loses the rest; the string reader is what a comparison must use. + assertEquals(0x30, r.valueFor("max_collection_backlog")) + assertEquals("0.0", r.stringValueFor("max_collection_backlog")) + } + + @Test + fun stringValueStopsAtNulSoEnvelopePaddingIsNotRead() { + val rec = echoRecord("enable_raw_data_w_ecg", "1") + ByteArray(3) + val r = DeviceConfigReadProbe.ValueResponse(1, rec) + assertEquals("1", r.stringValueFor("enable_raw_data_w_ecg")) + } + + @Test + fun stringValueClaimsNothingWhenThereIsNothingToClaim() { + assertNull( + DeviceConfigReadProbe.ValueResponse(1, echoRecord("enable_rfid", "0")) + .stringValueFor("enable_raw_data_w_ecg"), + ) + assertNull( + DeviceConfigReadProbe.ValueResponse(1, ByteArray(32)).stringValueFor("enable_raw_data_w_ecg"), + ) + assertNull( + DeviceConfigReadProbe.ValueResponse(1, echoRecord("enable_raw_data_w_ecg", "") + ByteArray(1)) + .stringValueFor("enable_raw_data_w_ecg"), + ) + assertNull( + DeviceConfigReadProbe.ValueResponse( + 1, echoRecord("enable_raw_data_w_ecg", "") + byteArrayOf(0xFF.toByte(), 0), + ).stringValueFor("enable_raw_data_w_ecg"), + ) + } + + // The verdict table: the ack never decides, the read-back does ----------------------------------- + + @Test + fun confirmedOnlyWhenTheReadBackReturnsWhatWasAsked() { + val report = EcgRawDataGateReport(true) + assertEquals(EcgRawDataGateReport.Verdict.PENDING, report.verdict) + report.noteWriteAck(1) + // A SUCCESS ack alone must NOT reach a verdict — #891's whole lesson. + assertEquals(EcgRawDataGateReport.Verdict.PENDING, report.verdict) + report.noteReadBack(parseReadBack(readBackFrame(echoRecord("enable_raw_data_w_ecg", "1")))) + assertEquals(EcgRawDataGateReport.Verdict.CONFIRMED, report.verdict) + assertEquals("1", report.storedValue) + assertTrue(report.render().contains("enable_raw_data_w_ecg")) + } + + @Test + fun successAckWithAnUnmovedValueIsUnchangedNotSuccess() { + // The exact failure mode this design exists for: the strap acks SUCCESS and the value did not move. + val report = EcgRawDataGateReport(true) + report.noteWriteAck(1) + report.noteReadBack(parseReadBack(readBackFrame(echoRecord("enable_raw_data_w_ecg", "0")))) + assertEquals(EcgRawDataGateReport.Verdict.UNCHANGED, report.verdict) + assertEquals("0", report.storedValue) + assertTrue(report.summary.contains("did NOT take")) + } + + @Test + fun turningTheGateBackOffConfirmsOnZero() { + val report = EcgRawDataGateReport(false) + assertEquals("0", report.requested) + report.noteReadBack(parseReadBack(readBackFrame(echoRecord("enable_raw_data_w_ecg", "0")))) + assertEquals(EcgRawDataGateReport.Verdict.CONFIRMED, report.verdict) + } + + @Test + fun refusedSilentNotClaimedAndUndecodableAreNeverSuccess() { + val refused = EcgRawDataGateReport(true) + refused.noteReadBack(DeviceConfigReadProbe.ValueResponse(0, ByteArray(0))) + assertEquals(EcgRawDataGateReport.Verdict.REFUSED, refused.verdict) + + val unsupported = EcgRawDataGateReport(true) + unsupported.noteReadBack(DeviceConfigReadProbe.ValueResponse(3, ByteArray(0))) + assertEquals(EcgRawDataGateReport.Verdict.REFUSED, unsupported.verdict) + + val notClaimed = EcgRawDataGateReport(true) + notClaimed.noteReadBack(DeviceConfigReadProbe.ValueResponse(1, echoRecord("enable_rfid", "1"))) + assertEquals(EcgRawDataGateReport.Verdict.NOT_CLAIMED, notClaimed.verdict) + + val silent = EcgRawDataGateReport(true) + silent.noteReadBackTimeout(8) + assertEquals(EcgRawDataGateReport.Verdict.SILENT, silent.verdict) + + val undecodable = EcgRawDataGateReport(true) + undecodable.noteReadBackFailure(DeviceConfigReadProbe.ParseFailure.CRC) + assertEquals(EcgRawDataGateReport.Verdict.UNDECODABLE, undecodable.verdict) + + for (r in listOf(refused, unsupported, notClaimed, silent, undecodable)) { + assertNotEquals(EcgRawDataGateReport.Verdict.CONFIRMED, r.verdict) + assertTrue(r.summary.isNotEmpty()) + } + } + + @Test + fun readBackIsCrcGatedLikeEveryOtherDecode() { + val frame = readBackFrame(echoRecord("enable_raw_data_w_ecg", "1")) + frame[frame.size - 1] = (frame[frame.size - 1].toInt() xor 0xFF).toByte() + val parsed = DeviceConfigReadProbe.parse(frame, DeviceFamily.WHOOP5, 121) + assertNull(parsed.value) + assertEquals(DeviceConfigReadProbe.ParseFailure.CRC, parsed.failure) + } + + @Test + fun renderNamesTheKeyTheVerbsAndTheOpenQuestion() { + val report = EcgRawDataGateReport(true) + report.noteWriteAck(1) + report.noteReadBackTimeout(8) + val text = report.render() + assertTrue(text.contains("SET_DEVICE_CONFIG_VALUE(119)")) + assertTrue(text.contains("GET_DEVICE_CONFIG_VALUE(121)")) + assertTrue(text.contains("SET_FF_VALUE(120) is never sent")) + // The honest framing has to survive into the copyable report a user pastes into an issue. + assertTrue(text.contains("UNKNOWN")) + assertTrue(text.contains("#891")) + } +}