From a0eb5a2f794bb07b5b28044d4fe4f487028b7819 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/3] 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 4e476457bf936b7cecd232157b7de12c46855b89 Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:01:42 -0400 Subject: [PATCH 2/3] Stop the config probe reporting a refusal as an enumeration, and a wrong body shape as a clean negative (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two verdict lines on this branch made positive claims from runs that had established nothing. Both are the same class of defect as the one just fixed in Whoop5EcgProbe (#896): a null read as a result. 1. "the strap enumerated its device-config namespace and returned no key NOOP did not already have" fired on `enumerationVerb == .answered` alone, and `noteEnumerationStart` treated ONLY result 3 as a refusal. So a strap that answered START_DEVICE_CONFIG_KEY_EXCHANGE(115) with FAILURE(0) and a zeroed record parsed fine (count = 0), the 116 follow-up decoded as exhausted, the walk ended with zero names — and the report asserted a successful, complete enumeration of a namespace nobody had listed. The same string also fired when the walk was TRUNCATED at maxEnumerationSteps (the cap went to stopReason only) and when every name had been enumerationSkipped, i.e. when OUR parser was the thing that failed. Now only SUCCESS(1) — or WHOOP 4.0, where the result byte's meaning has never been pinned here — opens a walk; 0 and 2 land on a new `inconclusive` status and 116 is never asked. The completeness claim additionally requires a walk that actually ran, reached the strap's own end marker, was not capped, and whose entries all decoded, with a distinct wording for each way that fails. The skipped>0 branch names our parser rather than the strap — the #874 discipline FeatureFlagProbe.verdict already carries, which this walk inherits along with its parser. 2. "this firmware has none of them (a clean negative)" fired on `unknown == asked` without checking that the run's calibration control had answered. ConfigKeySweep.existence maps FAILURE(0) to "no key by this name", but ValueResponse.isFailure documents the same code as "the verb exists, the request did not satisfy it (wrong body shape, or an unknown key)" — and the request body is inferred from the SET side, never observed. Since setStatus marks a verb `answered` for any non-UNSUPPORTED reply, a run in which the known-good control key `whoop_live_hr_in_adv_ind_pkt` (#181) itself returned FAILURE still reached "a clean negative", publishing our own body shape as the non-existence of a whole derivation family of names. The wording is now gated on `oracleCalibrated`: at least one .exists among the discovery/knownKey readings of the SAME run. Without it the verdict says the oracle is uncalibrated and the run is inconclusive. Ported to the Kotlin twin; the verdict strings stay byte-identical across platforms. The existing golden reports are unchanged — that scenario refuses 115 as UNSUPPORTED and its control answers SUCCESS, so neither gate moves it. Tests: ten new cases per side covering the FAILURE(0) and PENDING(2) replies to 115, the WHOOP 4.0 nil-result path, a walk that listed nothing, a walk whose names all failed our parser, a capped walk, a walk with no end marker, the uncalibrated-oracle sweep that was previously untested, and — as the guard against over-correcting — the completed walk and the calibrated sweep that must keep their original wording. Swift 446 tests / 0 failures. Android 3218 tests / 1 failure, the pre-existing DeepCaptureMigrationTest.repositoryInsertV18Aux_insertsThenPrunes (#897). Not touched here: the `answered == 0` "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" fallback, which is verbatim identical on main and is being fixed separately on fix/config-read-probe-verdict-honesty. Fixing it here too would collide. --- .../WhoopProtocol/DeviceConfigReadProbe.swift | 95 +++++++- .../DeviceConfigReadProbeTests.swift | 187 ++++++++++++++- .../noop/protocol/DeviceConfigReadProbe.kt | 115 ++++++++- .../protocol/DeviceConfigReadProbeTest.kt | 227 +++++++++++++++++- 4 files changed, 609 insertions(+), 15 deletions(-) diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift index a7d40aadbe..55143a89af 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift @@ -324,6 +324,10 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { case answered /// The firmware refused the opcode (5/MG result code 3). case unsupported + /// The verb replied, but with neither a SUCCESS nor an explicit UNSUPPORTED — `FAILURE(0)` or + /// `PENDING(2)`. Used by the enumeration pair, where a non-SUCCESS start means no walk happened + /// at all: the request was declined, which says nothing about what the namespace contains. + case inconclusive /// No reply inside the probe's per-step window. case silent /// A reply arrived but could not be decoded (CRC, envelope, or a short record). @@ -384,6 +388,14 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// 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 + /// True once the strap served its own end marker. The ONLY signal that the list is complete: every + /// other way the walk can stop (the cap, a timeout, an undecodable reply, the global step budget) + /// leaves a PREFIX of the namespace, which supports no claim about what the namespace omits. + public private(set) var enumerationReachedEnd = false + /// True when the walk was cut off by `ConfigKeySweep.maxEnumerationSteps` rather than by the strap. + /// Reported here as well as in `stopReason` because it is the verdict, not just the transcript, that + /// must stop short of a completeness claim. + public private(set) var enumerationTruncated = false /// `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? @@ -456,6 +468,7 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { guard enumSteps < ConfigKeySweep.maxEnumerationSteps else { stopReason = stopReason ?? "device-config enumeration hit its cap of \(ConfigKeySweep.maxEnumerationSteps) entries; the rest of the plan still ran" + enumerationTruncated = true enumPhase = 2 return nil } @@ -545,14 +558,28 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// 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. + /// + /// Only `SUCCESS(1)` — or WHOOP 4.0, where this codebase has never pinned the result byte's meaning — + /// opens a walk. `FAILURE(0)` and `PENDING(2)` are the firmware declining THIS request, and a decline + /// must never reach the verdict as an enumeration that happened to list nothing: the same + /// `isFailure` doc that governs the VALUE verbs says a FAILURE is equally consistent with the request + /// body being the wrong shape, and that body is inferred from the SET side rather than observed. public mutating func noteEnumerationStart(_ r: FeatureFlagProbe.StartResponse) { - enumeratedCount = r.count - if r.resultCode == 3 { - enumerationVerb = .unsupported + if let code = r.resultCode, code != 1 { enumPhase = 2 - trace.append("START_DEVICE_CONFIG_KEY_EXCHANGE(115) → result=UNSUPPORTED(3) — the firmware does not serve this verb") + if code == 3 { + enumeratedCount = r.count + enumerationVerb = .unsupported + trace.append("START_DEVICE_CONFIG_KEY_EXCHANGE(115) → result=UNSUPPORTED(3) — the firmware does not serve this verb") + return + } + // Deliberately NOT recording the announced count: a reply that declined the request has not + // told us how many keys exist, and a count kept here would read downstream as if it had. + enumerationVerb = .inconclusive + trace.append("START_DEVICE_CONFIG_KEY_EXCHANGE(115) → result=\(FeatureFlagProbe.resultLabel(code))(\(code)) — the verb answered but did not start a walk; nothing was enumerated") return } + enumeratedCount = r.count enumerationVerb = .answered enumPhase = 1 var line = "START_DEVICE_CONFIG_KEY_EXCHANGE(115) →" @@ -571,6 +598,7 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { public mutating func noteEnumerationNext(_ r: FeatureFlagProbe.NextResponse) -> Bool { if r.isExhausted { enumPhase = 2 + enumerationReachedEnd = true trace.append("SEND_NEXT_DEVICE_CONFIG(116) → end of list (index=\(r.index) validKey=\(r.validKey))") return false } @@ -673,12 +701,62 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { return out } + /// Whether anything in this run calibrated the existence oracle. + /// + /// `ConfigKeySweep.existence` maps `FAILURE(0)` to "the firmware has no key by this name", but + /// `ValueResponse.isFailure` documents the same code as "the verb exists, the request did not satisfy + /// it (**wrong body shape**, or an unknown key)" — and this file's own header records the request body + /// as inferred from the SET side, never observed. Those two readings are only separable by a CONTROL: + /// a key already known to exist answering `SUCCESS(1)` on the same path in the same run. The discovery + /// read of `deviceConfigDiscoveryKey` (hardware-validated in #181) and the sixteen flags NOOP writes + /// are those controls. Without one, an all-FAILURE sweep is a statement about our body shape, not + /// about the names — the defect fixed in `Whoop5EcgProbe` (#896) and the reason this gate exists. + public var oracleCalibrated: Bool { + readings.contains { ($0.group == .discovery || $0.group == .knownKey) && $0.existence == .exists } + } + + /// Entries the walk actually served, decoded or not. + private var enumerationWalked: Int { enumeratedKeys.count + enumerationSkipped } + /// 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 { + // "the namespace contains nothing new" is a positive claim about a LIST, so it needs a list: + // a walk that ran, reached the strap's own end marker, and whose every entry this parser + // could read. Each way that fails gets its own wording rather than one blanket assertion. + if enumeratedKeys.isEmpty && enumerationSkipped > 0 { + // #874, inherited: blaming the strap for OUR decode is the opposite conclusion, and it is + // the one a reader would carry into #103. + return "115 answered and the strap named \(enumerationSkipped) device-config entr(ies), none of " + + "which decoded as printable ASCII within \(FeatureFlagProbe.maxKeyLength) chars — this is " + + "our parser rejecting them, NOT the strap serving blanks; see the trace for the raw replies" + } + if enumerationWalked == 0 { + var line = "115 answered but listed no key — enumeration inconclusive" + if let c = enumeratedCount, c > 0 { line += " (the strap announced \(c))" } + return line + } + if enumerationTruncated { + return "115 answered; the walk stopped at its cap of \(ConfigKeySweep.maxEnumerationSteps) " + + "entries with nothing new to NOOP — a PREFIX of the namespace, so nothing is claimed " + + "about what the rest of it holds" + } + if !enumerationReachedEnd { + return "115 answered; \(enumerationWalked) entr(ies) were walked with nothing new to NOOP, but the " + + "strap never served its end marker — the namespace is only partly listed" + } + if enumerationSkipped > 0 { + return "115 answered; \(enumeratedKeys.count) key(s) listed, none new to NOOP, but " + + "\(enumerationSkipped) further entr(ies) did not decode as printable ASCII here — this is " + + "our parser rejecting them, NOT the strap serving blanks, so the namespace is not fully listed" + } + if let c = enumeratedCount, c != enumerationWalked { + return "115 answered; the strap announced \(c) device-config key(s) but served " + + "\(enumerationWalked) — the namespace is only partly listed" + } 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 @@ -698,6 +776,13 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { } let unknown = candidateReadings.filter { $0.existence == .unknown }.count if unknown == asked { + // A negative about a whole DERIVATION FAMILY of names is only worth publishing when the run + // proved the oracle can say yes. If the known-good control failed the same way the guesses + // did, the shared explanation is our request body, not the absence of every name asked. + guard oracleCalibrated else { + return "asked \(asked) candidate key name(s); all returned FAILURE, but the known-good control " + + "key did too — the oracle is uncalibrated this run (inconclusive)" + } 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" @@ -749,6 +834,8 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { sb += " (none — the reply did not decode)\n" case .answered: sb += " (none — 115 answered but the walk produced no names)\n" + case .inconclusive: + sb += " (none — 115 replied but declined to start a walk; the namespace was never listed)\n" case .untried: sb += " (none — not reached)\n" } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift index 408a4241ec..e8f228416f 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift @@ -507,18 +507,201 @@ final class DeviceConfigReadProbeTests: XCTestCase { /// 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) + /// + /// `control` is the result code every NON-candidate step is answered with. `1` is the calibrated run: + /// the known-good keys exist, so a later FAILURE on a guessed name really does mean "no such key". + /// `0` is the uncalibrated run, where even the control failed and the oracle has proved nothing. + private func driveToCandidates(limit: Int, control: Int = 1) -> (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) + let record = control == 1 ? echoRecord(step.key, value: 0x32) : [] + report.noteReply(.init(resultCode: control, record: record), for: step) } return (report, nil) } + // MARK: - The oracle's calibration control (a FAILURE only means "no such key" once a known key passed) + + /// The defect this pins: `FAILURE(0)` is documented in `ValueResponse.isFailure` as "the verb exists, + /// the request did not satisfy it (**wrong body shape**, or an unknown key)", and the request body is + /// itself an inference from the SET side. So an all-FAILURE sweep is only a statement about the NAMES + /// when a name known to exist answered SUCCESS in the same run. Here the known-good control failed + /// too, so the run proves nothing about the candidates and must not be published as a clean negative. + func testAnAllFailureSweepIsNotACleanNegativeWhenTheControlFailedToo() { + var (report, first) = driveToCandidates(limit: 2, control: 0) + guard var step = first else { return XCTFail("no candidate") } + while true { + report.noteReply(.init(resultCode: 0, record: []), for: step) + guard let next = report.nextStep() else { break } + step = next + } + XCTAssertFalse(report.oracleCalibrated, + "no discovery or known-key read came back SUCCESS, so nothing calibrated the oracle") + XCTAssertEqual(report.verdict, + "asked 2 candidate key name(s); all returned FAILURE, but the known-good control " + + "key did too — the oracle is uncalibrated this run (inconclusive)") + XCTAssertFalse(report.verdict.contains("clean negative"), + "an uncalibrated run must never publish a negative about the names") + } + + /// The other side of the same gate: when the control DID answer, a fully-negative sweep is a real + /// result and keeps its original wording. + func testTheCleanNegativeSurvivesWhenTheControlAnswered() { + var (report, first) = driveToCandidates(limit: 2) + guard var step = first else { return XCTFail("no candidate") } + while true { + report.noteReply(.init(resultCode: 0, record: []), for: step) + guard let next = report.nextStep() else { break } + step = next + } + XCTAssertTrue(report.oracleCalibrated) + XCTAssertEqual(report.verdict, + "asked 2 candidate key name(s); this firmware has none of them (a clean negative)") + } + + // MARK: - Opcode 115: only SUCCESS starts a walk + + /// The headline defect: `noteEnumerationStart` treated every code except UNSUPPORTED(3) as an + /// enumeration, so a strap that answers 115 with FAILURE(0) and a zeroed record produced a verb marked + /// `answered`, an empty key list, and a verdict asserting a complete enumeration of the namespace — + /// a positive claim built from a refusal. + func testAFailureReplyToOpcode115IsNotAnEnumeration() { + var report = smallReport() + guard let s1 = report.nextStep() else { return XCTFail("s1") } + XCTAssertEqual(s1.opcode, 115) + report.noteEnumerationStart(startReply(enumStart(result: 0, revision: 0, count: 0))) + + XCTAssertNotEqual(report.enumerationVerb, .answered, "a FAILURE never reads as an enumeration") + XCTAssertEqual(report.enumerationVerb, .inconclusive) + + guard let s2 = report.nextStep() else { return XCTFail("s2") } + XCTAssertEqual(s2.group, .discovery, "116 must not be asked once 115 declined the request") + XCTAssertNotEqual(s2.opcode, ConfigKeySweep.sendNextDeviceConfigCmd) + + XCTAssertFalse(report.verdict.contains("the strap enumerated its device-config namespace"), + "verdict was: \(report.verdict)") + XCTAssertTrue(report.render().contains("inconclusive")) + } + + /// PENDING(2) is the same class of answer: the verb replied, no walk started. + func testAPendingReplyToOpcode115IsNotAnEnumerationEither() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 2, revision: 0, count: 0))) + XCTAssertEqual(report.enumerationVerb, .inconclusive) + XCTAssertFalse(report.verdict.contains("the strap enumerated its device-config namespace")) + XCTAssertTrue(report.trace.contains { $0.contains("PENDING(2)") && $0.contains("did not start a walk") }) + } + + /// WHOOP 4.0 carries no pinned result byte, so nil must still open the walk rather than being + /// swallowed by the new gate. + func testAWhoop4StartWithNoResultCodeStillOpensTheWalk() { + var report = DeviceConfigReadProbeReport(family: .whoop4, + knownFlagKeys: ["enable_r22_packets"], + batch: ConfigKeySweep.batch(from: 0, limit: 1)) + _ = report.nextStep() + report.noteEnumerationStart(.init(resultCode: nil, revision: 10, count: 2)) + XCTAssertEqual(report.enumerationVerb, .answered) + guard let s2 = report.nextStep() else { return XCTFail("s2") } + XCTAssertEqual(s2.opcode, ConfigKeySweep.sendNextDeviceConfigCmd) + } + + // MARK: - A walk only proves a namespace when it actually walked + + /// 115 answered SUCCESS and announced two keys, but the very first 116 was the end marker. The walk + /// listed nothing, so the run cannot say what the namespace does or does not contain. + func testAnEnumerationThatListedNothingIsInconclusiveNotAnEmptyNamespace() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 2))) + guard let s2 = report.nextStep() else { return XCTFail("s2") } + XCTAssertEqual(s2.opcode, ConfigKeySweep.sendNextDeviceConfigCmd) + XCTAssertFalse(report.noteEnumerationNext(nextReply(enumNext(index: 0xFF, key: nil, validKey: false)))) + + XCTAssertTrue(report.enumeratedKeys.isEmpty) + XCTAssertEqual(report.enumerationVerb, .answered) + XCTAssertEqual(report.verdict, + "115 answered but listed no key — enumeration inconclusive (the strap announced 2)") + } + + /// Every entry the strap served was a real key it could not render for us. Blaming the strap for OUR + /// parser is the #874 defect; `FeatureFlagProbe.verdict` already carries this branch and the walk that + /// borrows its parser must carry it too. + func testAnEnumerationWhoseNamesAllFailedOurParserBlamesOurParserNotTheStrap() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 2))) + for i in 1...2 { + _ = report.nextStep() + XCTAssertTrue(report.noteEnumerationNext( + FeatureFlagProbe.NextResponse(resultCode: 1, revision: 10, index: i, + validKey: true, key: nil))) + } + _ = report.nextStep() + XCTAssertFalse(report.noteEnumerationNext(nextReply(enumNext(index: 0xFF, key: nil, validKey: false)))) + + XCTAssertEqual(report.enumerationSkipped, 2) + XCTAssertTrue(report.enumeratedKeys.isEmpty) + let v = report.verdict + XCTAssertTrue(v.contains("this is our parser rejecting them, NOT the strap serving blanks"), v) + XCTAssertFalse(v.contains("returned no key NOOP did not already have"), v) + } + + /// A walk truncated at `maxEnumerationSteps` has seen a PREFIX of the namespace. The cap was reported + /// in `stopReason` only, while the verdict went on claiming a complete listing. + func testAnEnumerationTruncatedAtTheCapMakesNoCompletenessClaim() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 9999))) + // A strap with more keys than the cap allows: every entry is a name NOOP already has, so nothing + // is "new" and the verdict falls through to the completeness claim. + var walked = 0 + while let step = report.nextStep(), step.group == .enumerate { + walked += 1 + _ = report.noteEnumerationNext(nextReply(enumNext(index: 1, key: "enable_r22_packets"))) + } + XCTAssertEqual(walked, ConfigKeySweep.maxEnumerationSteps) + XCTAssertTrue(report.enumerationTruncated) + XCTAssertTrue(report.newKeysFound.isEmpty) + let v = report.verdict + XCTAssertTrue(v.contains("stopped at its cap"), v) + XCTAssertFalse(v.contains("returned no key NOOP did not already have"), v) + } + + /// A walk cut off before the strap's own end marker is likewise a prefix, not a namespace. + func testAWalkThatNeverReachedTheEndMarkerMakesNoCompletenessClaim() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 2))) + _ = report.nextStep() + XCTAssertTrue(report.noteEnumerationNext(nextReply(enumNext(index: 1, key: "enable_r22_packets")))) + // The strap stops replying: the pair is retired mid-walk, and no end marker was ever served. + guard let s3 = report.nextStep() else { return XCTFail("s3") } + report.noteTimeout(for: s3, seconds: 8) + XCTAssertFalse(report.enumerationReachedEnd) + XCTAssertFalse(report.verdict.contains("returned no key NOOP did not already have")) + } + + /// The guard against over-correcting: a walk that really did complete, and really did list only keys + /// NOOP already had, keeps the original claim word for word. + func testACompletedWalkOfOnlyKnownKeysStillReadsAsACompleteEnumeration() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 1))) + _ = report.nextStep() + XCTAssertTrue(report.noteEnumerationNext(nextReply(enumNext(index: 1, key: "enable_r22_packets")))) + _ = report.nextStep() + XCTAssertFalse(report.noteEnumerationNext(nextReply(enumNext(index: 0xFF, key: nil, validKey: false)))) + XCTAssertTrue(report.enumerationReachedEnd) + XCTAssertTrue(report.newKeysFound.isEmpty) + XCTAssertEqual(report.verdict, + "the strap enumerated its device-config namespace and returned no key NOOP did not already have") + } + // MARK: - Report /// Byte-for-byte golden, asserted identically by the Kotlin twin, so a shared strap log reads the same 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..5d18c71a48 100644 --- a/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt +++ b/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt @@ -297,6 +297,11 @@ class DeviceConfigReadProbeReport( /** The firmware refused the opcode (5/MG result code 3). */ UNSUPPORTED("unsupported"), + /** The verb replied, but with neither a SUCCESS nor an explicit UNSUPPORTED — `FAILURE(0)` or + * `PENDING(2)`. Used by the enumeration pair, where a non-SUCCESS start means no walk happened at + * all: the request was declined, which says nothing about what the namespace contains. */ + INCONCLUSIVE("inconclusive"), + /** No reply inside the probe's per-step window. */ SILENT("silent"), @@ -346,6 +351,18 @@ class DeviceConfigReadProbeReport( var enumerationSkipped: Int = 0 private set + /** True once the strap served its own end marker. The ONLY signal that the list is complete: every + * other way the walk can stop (the cap, a timeout, an undecodable reply, the global step budget) + * leaves a PREFIX of the namespace, which supports no claim about what the namespace omits. */ + var enumerationReachedEnd: Boolean = false + private set + + /** True when the walk was cut off by [ConfigKeySweep.MAX_ENUMERATION_STEPS] rather than by the strap. + * Reported here as well as in [stopReason] because it is the verdict, not just the transcript, that + * must stop short of a completeness claim. */ + var enumerationTruncated: Boolean = false + 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 @@ -424,6 +441,7 @@ class DeviceConfigReadProbeReport( stopReason = "device-config enumeration hit its cap of " + "${ConfigKeySweep.MAX_ENUMERATION_STEPS} entries; the rest of the plan still ran" } + enumerationTruncated = true enumPhase = 2 null } else { @@ -546,22 +564,39 @@ class DeviceConfigReadProbeReport( * 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. + * + * Only `SUCCESS(1)` — or WHOOP 4.0, where this codebase has never pinned the result byte's meaning — + * opens a walk. `FAILURE(0)` and `PENDING(2)` are the firmware declining THIS request, and a decline + * must never reach the verdict as an enumeration that happened to list nothing: the same `isFailure` + * doc that governs the VALUE verbs says a FAILURE is equally consistent with the request body being the + * wrong shape, and that body is inferred from the SET side rather than observed. */ fun noteEnumerationStart(r: FeatureFlagProbe.StartResponse) { - enumeratedCount = r.count - if (r.resultCode == 3) { - enumerationVerb = VerbStatus.UNSUPPORTED + val code = r.resultCode + if (code != null && code != 1) { enumPhase = 2 + if (code == 3) { + enumeratedCount = r.count + enumerationVerb = VerbStatus.UNSUPPORTED + _trace.add( + "START_DEVICE_CONFIG_KEY_EXCHANGE(115) → result=UNSUPPORTED(3) — " + + "the firmware does not serve this verb", + ) + return + } + // Deliberately NOT recording the announced count: a reply that declined the request has not + // told us how many keys exist, and a count kept here would read downstream as if it had. + enumerationVerb = VerbStatus.INCONCLUSIVE _trace.add( - "START_DEVICE_CONFIG_KEY_EXCHANGE(115) → result=UNSUPPORTED(3) — " + - "the firmware does not serve this verb", + "START_DEVICE_CONFIG_KEY_EXCHANGE(115) → result=${FeatureFlagProbe.resultLabel(code)}($code) — " + + "the verb answered but did not start a walk; nothing was enumerated", ) return } + enumeratedCount = r.count 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)" @@ -578,6 +613,7 @@ class DeviceConfigReadProbeReport( fun noteEnumerationNext(r: FeatureFlagProbe.NextResponse): Boolean { if (r.isExhausted) { enumPhase = 2 + enumerationReachedEnd = true _trace.add("SEND_NEXT_DEVICE_CONFIG(116) → end of list (index=${r.index} validKey=${r.validKey})") return false } @@ -694,6 +730,28 @@ class DeviceConfigReadProbeReport( return out } + /** + * Whether anything in this run calibrated the existence oracle. + * + * [ConfigKeySweep.existence] maps `FAILURE(0)` to "the firmware has no key by this name", but + * [DeviceConfigReadProbe.ValueResponse.isFailure] documents the same code as "the verb exists, the + * request did not satisfy it (**wrong body shape**, or an unknown key)" — and this file's own header + * records the request body as inferred from the SET side, never observed. Those two readings are only + * separable by a CONTROL: a key already known to exist answering `SUCCESS(1)` on the same path in the + * same run. The discovery read of [DeviceConfigReadProbe.DEVICE_CONFIG_DISCOVERY_KEY] (hardware- + * validated in #181) and the sixteen flags NOOP writes are those controls. Without one, an all-FAILURE + * sweep is a statement about our body shape, not about the names — the defect fixed in `Whoop5EcgProbe` + * (#896) and the reason this gate exists. + */ + val oracleCalibrated: Boolean + get() = _readings.any { + (it.group == Group.DISCOVERY || it.group == Group.KNOWN_KEY) && + it.existence == ConfigKeySweep.Existence.EXISTS + } + + /** Entries the walk actually served, decoded or not. */ + private val enumerationWalked: Int get() = _enumeratedKeys.size + enumerationSkipped + /** One-line summary of what the probe established. */ val verdict: String get() { @@ -702,6 +760,42 @@ class DeviceConfigReadProbeReport( return "${found.size} config key name(s) found that NOOP did not have: ${found.joinToString(", ")}" } if (enumerationVerb == VerbStatus.ANSWERED) { + // "the namespace contains nothing new" is a positive claim about a LIST, so it needs a + // list: a walk that ran, reached the strap's own end marker, and whose every entry this + // parser could read. Each way that fails gets its own wording rather than one blanket + // assertion. + if (_enumeratedKeys.isEmpty() && enumerationSkipped > 0) { + // #874, inherited: blaming the strap for OUR decode is the opposite conclusion, and it + // is the one a reader would carry into #103. + return "115 answered and the strap named $enumerationSkipped device-config entr(ies), none of " + + "which decoded as printable ASCII within ${FeatureFlagProbe.MAX_KEY_LENGTH} chars — this is " + + "our parser rejecting them, NOT the strap serving blanks; see the trace for the raw replies" + } + if (enumerationWalked == 0) { + var line = "115 answered but listed no key — enumeration inconclusive" + val c = enumeratedCount + if (c != null && c > 0) line += " (the strap announced $c)" + return line + } + if (enumerationTruncated) { + return "115 answered; the walk stopped at its cap of ${ConfigKeySweep.MAX_ENUMERATION_STEPS} " + + "entries with nothing new to NOOP — a PREFIX of the namespace, so nothing is claimed " + + "about what the rest of it holds" + } + if (!enumerationReachedEnd) { + return "115 answered; $enumerationWalked entr(ies) were walked with nothing new to NOOP, but the " + + "strap never served its end marker — the namespace is only partly listed" + } + if (enumerationSkipped > 0) { + return "115 answered; ${_enumeratedKeys.size} key(s) listed, none new to NOOP, but " + + "$enumerationSkipped further entr(ies) did not decode as printable ASCII here — this is " + + "our parser rejecting them, NOT the strap serving blanks, so the namespace is not fully listed" + } + val announced = enumeratedCount + if (announced != null && announced != enumerationWalked) { + return "115 answered; the strap announced $announced device-config key(s) but served " + + "$enumerationWalked — the namespace is only partly listed" + } 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 } @@ -722,6 +816,13 @@ class DeviceConfigReadProbeReport( } val unknown = candidateReadings.count { it.existence == ConfigKeySweep.Existence.UNKNOWN } if (unknown == asked) { + // A negative about a whole DERIVATION FAMILY of names is only worth publishing when the run + // proved the oracle can say yes. If the known-good control failed the same way the guesses + // did, the shared explanation is our request body, not the absence of every name asked. + if (!oracleCalibrated) { + return "asked $asked candidate key name(s); all returned FAILURE, but the known-good control " + + "key did too — the oracle is uncalibrated this run (inconclusive)" + } 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" @@ -785,6 +886,8 @@ class DeviceConfigReadProbeReport( 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.INCONCLUSIVE -> + " (none — 115 replied but declined to start a walk; the namespace was never listed)\n" VerbStatus.UNTRIED -> " (none — not reached)\n" }, ) 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..32101b5481 100644 --- a/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt +++ b/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt @@ -482,16 +482,237 @@ class DeviceConfigReadProbeTest { // ---- 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 { + * alongside the report (a pulled step cannot be pushed back). + * + * [control] is the result code every NON-candidate step is answered with. `1` is the calibrated run: + * the known-good keys exist, so a later FAILURE on a guessed name really does mean "no such key". `0` + * is the uncalibrated run, where even the control failed and the oracle has proved nothing. */ + private fun driveToCandidates( + limit: Int, + control: Int = 1, + ): Pair { val report = smallReport(limit) report.nextStep() report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) while (true) { 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) + val record = if (control == 1) echoRecord(step.key, 0x32) else ByteArray(0) + report.noteReply(valueReply(control, record), step) + } + } + + // ---- The oracle's calibration control (a FAILURE only means "no such key" once a known key passed) ---- + + /** The defect this pins: `FAILURE(0)` is documented in `ValueResponse.isFailure` as "the verb exists, + * the request did not satisfy it (**wrong body shape**, or an unknown key)", and the request body is + * itself an inference from the SET side. So an all-FAILURE sweep is only a statement about the NAMES + * when a name known to exist answered SUCCESS in the same run. Here the known-good control failed too, + * so the run proves nothing about the candidates and must not be published as a clean negative. */ + @Test + fun anAllFailureSweepIsNotACleanNegativeWhenTheControlFailedToo() { + val (report, first) = driveToCandidates(2, control = 0) + var step = first!! + while (true) { + report.noteReply(valueReply(0, ByteArray(0)), step) + step = report.nextStep() ?: break + } + assertFalse( + "no discovery or known-key read came back SUCCESS, so nothing calibrated the oracle", + report.oracleCalibrated, + ) + assertEquals( + "asked 2 candidate key name(s); all returned FAILURE, but the known-good control " + + "key did too — the oracle is uncalibrated this run (inconclusive)", + report.verdict, + ) + assertFalse( + "an uncalibrated run must never publish a negative about the names", + report.verdict.contains("clean negative"), + ) + } + + /** The other side of the same gate: when the control DID answer, a fully-negative sweep is a real + * result and keeps its original wording. */ + @Test + fun theCleanNegativeSurvivesWhenTheControlAnswered() { + val (report, first) = driveToCandidates(2) + var step = first!! + while (true) { + report.noteReply(valueReply(0, ByteArray(0)), step) + step = report.nextStep() ?: break + } + assertTrue(report.oracleCalibrated) + assertEquals( + "asked 2 candidate key name(s); this firmware has none of them (a clean negative)", + report.verdict, + ) + } + + // ---- Opcode 115: only SUCCESS starts a walk ---- + + /** The headline defect: `noteEnumerationStart` treated every code except UNSUPPORTED(3) as an + * enumeration, so a strap that answers 115 with FAILURE(0) and a zeroed record produced a verb marked + * `answered`, an empty key list, and a verdict asserting a complete enumeration of the namespace — a + * positive claim built from a refusal. */ + @Test + fun aFailureReplyToOpcode115IsNotAnEnumeration() { + val report = smallReport() + val s1 = report.nextStep()!! + assertEquals(115, s1.opcode) + report.noteEnumerationStart(startReply(enumStart(0, 0, 0))) + + assertFalse( + "a FAILURE never reads as an enumeration", + report.enumerationVerb == DeviceConfigReadProbeReport.VerbStatus.ANSWERED, + ) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.INCONCLUSIVE, report.enumerationVerb) + + val s2 = report.nextStep()!! + assertEquals( + "116 must not be asked once 115 declined the request", + DeviceConfigReadProbeReport.Group.DISCOVERY, + s2.group, + ) + assertFalse(s2.opcode == ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD) + + assertFalse( + report.verdict, + report.verdict.contains("the strap enumerated its device-config namespace"), + ) + assertTrue(report.render().contains("inconclusive")) + } + + /** PENDING(2) is the same class of answer: the verb replied, no walk started. */ + @Test + fun aPendingReplyToOpcode115IsNotAnEnumerationEither() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(2, 0, 0))) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.INCONCLUSIVE, report.enumerationVerb) + assertFalse(report.verdict.contains("the strap enumerated its device-config namespace")) + assertTrue( + report.trace.any { it.contains("PENDING(2)") && it.contains("did not start a walk") }, + ) + } + + /** WHOOP 4.0 carries no pinned result byte, so null must still open the walk rather than being + * swallowed by the new gate. */ + @Test + fun aWhoop4StartWithNoResultCodeStillOpensTheWalk() { + val report = DeviceConfigReadProbeReport( + DeviceFamily.WHOOP4, + listOf("enable_r22_packets"), + ConfigKeySweep.batch(0, 1), + ) + report.nextStep() + report.noteEnumerationStart(FeatureFlagProbe.StartResponse(null, 10, 2)) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.ANSWERED, report.enumerationVerb) + assertEquals(ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD, report.nextStep()!!.opcode) + } + + // ---- A walk only proves a namespace when it actually walked ---- + + /** 115 answered SUCCESS and announced two keys, but the very first 116 was the end marker. The walk + * listed nothing, so the run cannot say what the namespace does or does not contain. */ + @Test + fun anEnumerationThatListedNothingIsInconclusiveNotAnEmptyNamespace() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(1, 10, 2))) + val s2 = report.nextStep()!! + assertEquals(ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD, s2.opcode) + assertFalse(report.noteEnumerationNext(nextReply(enumNext(0xFF, null, validKey = false)))) + + assertTrue(report.enumeratedKeys.isEmpty()) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.ANSWERED, report.enumerationVerb) + assertEquals( + "115 answered but listed no key — enumeration inconclusive (the strap announced 2)", + report.verdict, + ) + } + + /** Every entry the strap served was a real key it could not render for us. Blaming the strap for OUR + * parser is the #874 defect; `FeatureFlagProbe.verdict` already carries this branch and the walk that + * borrows its parser must carry it too. */ + @Test + fun anEnumerationWhoseNamesAllFailedOurParserBlamesOurParserNotTheStrap() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(1, 10, 2))) + for (i in 1..2) { + report.nextStep() + assertTrue( + report.noteEnumerationNext( + FeatureFlagProbe.NextResponse(1, 10, i, validKey = true, key = null), + ), + ) + } + report.nextStep() + assertFalse(report.noteEnumerationNext(nextReply(enumNext(0xFF, null, validKey = false)))) + + assertEquals(2, report.enumerationSkipped) + assertTrue(report.enumeratedKeys.isEmpty()) + val v = report.verdict + assertTrue(v, v.contains("this is our parser rejecting them, NOT the strap serving blanks")) + assertFalse(v, v.contains("returned no key NOOP did not already have")) + } + + /** A walk truncated at [ConfigKeySweep.MAX_ENUMERATION_STEPS] has seen a PREFIX of the namespace. The + * cap was reported in `stopReason` only, while the verdict went on claiming a complete listing. */ + @Test + fun anEnumerationTruncatedAtTheCapMakesNoCompletenessClaim() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(1, 10, 9999))) + // A strap with more keys than the cap allows: every entry is a name NOOP already has, so nothing + // is "new" and the verdict falls through to the completeness claim. + var walked = 0 + while (true) { + val step = report.nextStep() ?: break + if (step.group != DeviceConfigReadProbeReport.Group.ENUMERATE) break + walked += 1 + report.noteEnumerationNext(nextReply(enumNext(1, "enable_r22_packets"))) } + assertEquals(ConfigKeySweep.MAX_ENUMERATION_STEPS, walked) + assertTrue(report.enumerationTruncated) + assertTrue(report.newKeysFound.isEmpty()) + val v = report.verdict + assertTrue(v, v.contains("stopped at its cap")) + assertFalse(v, v.contains("returned no key NOOP did not already have")) + } + + /** A walk cut off before the strap's own end marker is likewise a prefix, not a namespace. */ + @Test + fun aWalkThatNeverReachedTheEndMarkerMakesNoCompletenessClaim() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(1, 10, 2))) + report.nextStep() + assertTrue(report.noteEnumerationNext(nextReply(enumNext(1, "enable_r22_packets")))) + // The strap stops replying: the pair is retired mid-walk, and no end marker was ever served. + report.noteTimeout(report.nextStep()!!, 8) + assertFalse(report.enumerationReachedEnd) + assertFalse(report.verdict.contains("returned no key NOOP did not already have")) + } + + /** The guard against over-correcting: a walk that really did complete, and really did list only keys + * NOOP already had, keeps the original claim word for word. */ + @Test + fun aCompletedWalkOfOnlyKnownKeysStillReadsAsACompleteEnumeration() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(1, 10, 1))) + report.nextStep() + assertTrue(report.noteEnumerationNext(nextReply(enumNext(1, "enable_r22_packets")))) + report.nextStep() + assertFalse(report.noteEnumerationNext(nextReply(enumNext(0xFF, null, validKey = false)))) + assertTrue(report.enumerationReachedEnd) + assertTrue(report.newKeysFound.isEmpty()) + assertEquals( + "the strap enumerated its device-config namespace and returned no key NOOP did not already have", + report.verdict, + ) } /** A fully-negative sweep is a RESULT, and the verdict must say so rather than reading like a From 114c45f217f73d298a535ddde42360ca710cd41b Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:49:15 -0400 Subject: [PATCH 3/3] ble: carry the config-read verdict-honesty fix onto the key sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This branch has the pre-fix fallback verbatim — 4e476457 called it out and left it alone deliberately — so merging would put the over-claim back after it is fixed on main by fix/config-read-probe-verdict-honesty. Same change, adapted to the verbs and statuses this branch has. The no-answer verdict printed "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" for two timeouts (the send path can return without transmitting at all), for one refusal plus one timeout (the `||` spoke for a verb that was never heard from), and for an undecodable reply (which is affirmative evidence the strap DID transmit). Each verb now reports the evidence it produced: "refused by firmware (UNSUPPORTED)", "served no reply in Ns — unconfirmed", "replied but the frame did not decode — unconfirmed", "not asked". noteTimeout records the window so the verdict can name it. The strong "not served by this firmware" sentence is kept only when the firmware refused BOTH value verbs itself — the `||` is now an `&&` — and it names only the two verbs it speaks about. `inconclusive`, added by 4e476457, gets "replied but declined the request — unconfirmed". Like `undecodable` it is the strap ANSWERING, so it is evidence the verb exists and must not be worded like a verb never heard from. The enumeration verb is named in the per-verb sentence too, since on this branch 115/116 is the headline read verb. It spans two opcodes, so `outcome` takes the list of commands a verb can ride on rather than a single one. swift test 452 passed, 0 failures; gradlew testFullDebugUnitTest 3224 tests, 1 failure — DeepCaptureMigrationTest.repositoryInsertV18Aux_insertsThenPrunes, pre-existing on this branch's base and fixed by #897 on main — verified from the JUnit XML (390 files, one mtime). Refs #103. --- .../WhoopProtocol/DeviceConfigReadProbe.swift | 64 +++++++- .../DeviceConfigReadProbeTests.swift | 110 ++++++++++++++ .../noop/protocol/DeviceConfigReadProbe.kt | 74 ++++++++-- .../protocol/DeviceConfigReadProbeTest.kt | 137 ++++++++++++++++++ 4 files changed, 369 insertions(+), 16 deletions(-) diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift index 55143a89af..e4f4f8878d 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift @@ -32,6 +32,14 @@ import Foundation /// equally useful and publishable — it is what promotes the guessing fallback from a shortcut to the only /// available method. /// +/// "Not served" is a claim about firmware, so the verdict makes it only on the firmware's own evidence. +/// A verb's outcomes are not interchangeable: **only UNSUPPORTED is the firmware saying it does not +/// serve the verb**. A timeout is a fact about one run and not even proof a frame reached the strap — +/// `BLEManager.send` returns without transmitting when there is no `cmdCharacteristic`, and again when +/// the 5/MG allowlist does not carry the opcode — while an undecodable reply, and an `inconclusive` one, +/// are both affirmative evidence the strap DID transmit, which is the opposite of "not served". All +/// three are reported as **unconfirmed**. +/// /// 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 @@ -409,6 +417,13 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { public private(set) var steps = 0 /// Set once the walk stopped for a reason worth naming beyond "the plan ran out". public private(set) var stopReason: String? + /// Per-verb reply window, in seconds, recorded by `noteTimeout`. The verdict names the window a verb + /// went silent through instead of converting that silence into a claim about the firmware. + private var silenceWindow: [UInt8: Int] = [:] + + /// The two commands the enumeration verb rides on; either can be the round-trip that went silent. + static let enumerationOpcodes: [UInt8] = [ConfigKeySweep.startDeviceConfigKeyExchangeCmd, + ConfigKeySweep.sendNextDeviceConfigCmd] // MARK: Plan cursors @@ -655,7 +670,12 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// Record the strap answering nothing at all within the per-step window. The verb is marked silent, /// which retires it — one no-reply must not cost another twenty timeouts. + /// + /// The window is kept, not just printed, because the verdict has to be able to say how long nothing + /// came back for. Silence is not the firmware refusing; it does not even establish that a frame was + /// transmitted (see the header), so it can never be reported as what the firmware serves. public mutating func noteTimeout(for step: Step, seconds: Int) { + silenceWindow[step.opcode] = seconds setStatus(.silent, for: step.opcode) trace.append("\(DeviceConfigReadProbeReport.opcodeLabel(step.opcode)) key=\"\(step.key)\" → no COMMAND_RESPONSE within \(seconds)s") } @@ -718,7 +738,34 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// Entries the walk actually served, decoded or not. private var enumerationWalked: Int { enumeratedKeys.count + enumerationSkipped } + /// How one verb ended, worded to exactly the evidence behind it. See the file header for why the + /// statuses are not interchangeable; the short version is that only `unsupported` is the firmware + /// saying it does not serve the verb, so only `unsupported` speaks for the firmware. `inconclusive` + /// is the strap ANSWERING and declining the request, which is evidence the verb exists. + /// + /// `opcodes` is every command that can carry the verb, because the enumeration verb spans 115 and 116 + /// and either one can be the round-trip that went silent. + private func outcome(_ status: VerbStatus, for opcodes: [UInt8]) -> String { + switch status { + case .untried: return "not asked" + case .answered: return "answered" + case .unsupported: return "refused by firmware (UNSUPPORTED)" + case .inconclusive: return "replied but declined the request — unconfirmed" + case .silent: + guard let seconds = opcodes.compactMap({ silenceWindow[$0] }).first else { + return "served no reply — unconfirmed" + } + return "served no reply in \(seconds)s — unconfirmed" + case .undecodable: return "replied but the frame did not decode — unconfirmed" + } + } + /// One-line summary of what the probe established. + /// + /// "not served by this firmware" is a claim about the firmware, so it is made in exactly one case: the + /// firmware refused BOTH value verbs itself. Every other run in which nothing answered reports each + /// verb against the evidence that verb produced, because two timeouts, one refusal plus one timeout, + /// and a reply that failed to decode are three different findings, not one. public var verdict: String { if !newKeysFound.isEmpty { return "\(newKeysFound.count) config key name(s) found that NOOP did not have: \(newKeysFound.joined(separator: ", "))" @@ -761,14 +808,17 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { } 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" - if featureFlagVerb == .unsupported || deviceConfigVerb == .unsupported { - return "\(both) — rejected as UNSUPPORTED" - } - if featureFlagVerb == .silent && deviceConfigVerb == .silent { - return "\(both) — no reply to either" + if featureFlagVerb == .unsupported && deviceConfigVerb == .unsupported { + // The one run that supports the strong sentence. It names only the two verbs it speaks + // about; 115/116's own status is in the Verbs table and is not claimed here. + return "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this " + + "firmware — rejected as UNSUPPORTED" } - return both + let en = outcome(enumerationVerb, for: DeviceConfigReadProbeReport.enumerationOpcodes) + let ff = outcome(featureFlagVerb, for: [DeviceConfigReadProbe.getFeatureFlagValueCmd]) + let dc = outcome(deviceConfigVerb, for: [DeviceConfigReadProbe.getDeviceConfigValueCmd]) + return "no read verb answered — device-config enumerate(115/116) \(en); " + + "GET_FF_VALUE(128) \(ff); GET_DEVICE_CONFIG_VALUE(121) \(dc)" } let asked = candidateReadings.count if asked == 0 { diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift index e8f228416f..de98f0d33f 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift @@ -353,6 +353,116 @@ final class DeviceConfigReadProbeTests: XCTestCase { XCTAssertTrue(report.render().contains("(none — no reply to 115)")) } + // MARK: - The verdict says only what the run established + + /// Every verb timing out is three timeouts, not a finding about firmware. `BLEManager.send` can + /// `return` without transmitting at all — no `cmdCharacteristic`, or a 5/MG allowlist that does not + /// carry the opcode — so a run in which nothing reached the strap must not print a claim about what + /// the firmware serves. + func testAWhollySilentRunNeverClaimsAFirmwareBehaviour() { + var report = smallReport() + while let step = report.nextStep() { report.noteTimeout(for: step, seconds: 8) } + XCTAssertEqual(report.enumerationVerb, .silent) + XCTAssertEqual(report.featureFlagVerb, .silent) + XCTAssertEqual(report.deviceConfigVerb, .silent) + XCTAssertEqual(report.verdict, + "no read verb answered — device-config enumerate(115/116) served no reply in 8s " + + "— unconfirmed; GET_FF_VALUE(128) served no reply in 8s — unconfirmed; " + + "GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed") + XCTAssertFalse(report.render().contains("served by this firmware"), + "silence is not the firmware answering — it is not even proof a frame was sent") + } + + /// One value verb refused and the other timed out: the refusal belongs to the verb that was refused. + /// The old `||` printed "neither … is served by this firmware — rejected as UNSUPPORTED" over a 121 + /// that was never refused, only never heard from. + func testOneRefusalIsNotGeneralisedToTheVerbThatWasNeverHeardFrom() { + var report = smallReport() + guard let s115 = report.nextStep() else { return XCTFail("115") } + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) + XCTAssertEqual(s115.opcode, 115) + + guard let s128 = report.nextStep() else { return XCTFail("128") } + XCTAssertEqual(s128.opcode, DeviceConfigReadProbe.getFeatureFlagValueCmd) + report.noteReply(.init(resultCode: 3, record: [0x00]), for: s128) + + guard let s121 = report.nextStep() else { return XCTFail("121") } + XCTAssertEqual(s121.opcode, DeviceConfigReadProbe.getDeviceConfigValueCmd) + report.noteTimeout(for: s121, seconds: 8) + + XCTAssertEqual(report.featureFlagVerb, .unsupported) + XCTAssertEqual(report.deviceConfigVerb, .silent) + XCTAssertEqual(report.verdict, + "no read verb answered — device-config enumerate(115/116) refused by firmware " + + "(UNSUPPORTED); GET_FF_VALUE(128) refused by firmware (UNSUPPORTED); " + + "GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed") + XCTAssertFalse(report.render().contains("neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121)"), + "one refusal does not speak for the other verb") + } + + /// Both value verbs refused BY THE FIRMWARE is the one run that supports the strong sentence, so it + /// keeps it. + func testBothValueVerbsRefusedKeepsTheStrongSentence() { + var report = smallReport() + guard report.nextStep() != nil else { return XCTFail("115") } + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) + for _ in 0..<2 { + guard let step = report.nextStep() else { return XCTFail("a value verb") } + report.noteReply(.init(resultCode: 3, record: [0x00]), for: step) + } + XCTAssertEqual(report.featureFlagVerb, .unsupported) + XCTAssertEqual(report.deviceConfigVerb, .unsupported) + XCTAssertEqual(report.verdict, + "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this " + + "firmware — rejected as UNSUPPORTED") + } + + /// An undecodable reply is affirmative evidence the strap DID transmit, so "not served by this + /// firmware" states the opposite of what the run observed. + func testAnUndecodableReplyIsNotReportedAsUnserved() { + var report = smallReport() + guard let s115 = report.nextStep() else { return XCTFail("115") } + report.noteFailure(.crc, for: s115) + guard let s128 = report.nextStep() else { return XCTFail("128") } + report.noteFailure(.envelope, for: s128) + guard let s121 = report.nextStep() else { return XCTFail("121") } + report.noteFailure(.truncated, for: s121) + + XCTAssertEqual(report.featureFlagVerb, .undecodable) + XCTAssertEqual(report.deviceConfigVerb, .undecodable) + XCTAssertEqual(report.verdict, + "no read verb answered — device-config enumerate(115/116) replied but the frame " + + "did not decode — unconfirmed; GET_FF_VALUE(128) replied but the frame did not " + + "decode — unconfirmed; GET_DEVICE_CONFIG_VALUE(121) replied but the frame did " + + "not decode — unconfirmed") + XCTAssertFalse(report.render().contains("served by this firmware")) + } + + /// An `inconclusive` enumeration is the strap ANSWERING and declining the request — evidence the verb + /// exists. It must never be worded like a verb that was never heard from. + func testAnInconclusiveEnumerationIsReportedAsAReplyNotAsSilence() { + var report = smallReport() + guard report.nextStep() != nil else { return XCTFail("115") } + report.noteEnumerationStart(startReply(enumStart(result: 0, revision: 0, count: 0))) + XCTAssertEqual(report.enumerationVerb, .inconclusive) + while let step = report.nextStep() { report.noteTimeout(for: step, seconds: 8) } + + XCTAssertEqual(report.verdict, + "no read verb answered — device-config enumerate(115/116) replied but declined " + + "the request — unconfirmed; GET_FF_VALUE(128) served no reply in 8s — " + + "unconfirmed; GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed") + XCTAssertFalse(report.render().contains("served by this firmware")) + } + + /// A probe that ended before any verb went out says so, rather than reporting an empty run as a + /// finding about the firmware. + func testAProbeThatAskedNothingClaimsNothing() { + let report = smallReport() + XCTAssertEqual(report.verdict, + "no read verb answered — device-config enumerate(115/116) not asked; " + + "GET_FF_VALUE(128) not asked; GET_DEVICE_CONFIG_VALUE(121) not asked") + } + func testAnUndecodableEnumerationReplyRetiresIt() { var report = smallReport() guard let s1 = report.nextStep() else { return XCTFail("s1") } 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 5d18c71a48..7c32f0cbc1 100644 --- a/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt +++ b/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt @@ -38,6 +38,14 @@ package com.noop.protocol * and publishable — it is what promotes the guessing fallback from a shortcut to the only available * method. * + * "Not served" is a claim about firmware, so the verdict makes it only on the firmware's own evidence. + * A verb's outcomes are not interchangeable: **only UNSUPPORTED is the firmware saying it does not serve + * the verb**. A timeout is a fact about one run and not even proof a frame reached the strap — the send + * path returns without transmitting when the command characteristic is missing, and again when the 5/MG + * allowlist does not carry the opcode — while an undecodable reply, and an INCONCLUSIVE one, are both + * affirmative evidence the strap DID transmit, which is the opposite of "not served". All three are + * reported as **unconfirmed**. + * * 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 @@ -276,6 +284,14 @@ class DeviceConfigReadProbeReport( val batch: ConfigKeySweep.Batch, ) { + private companion object { + /** The two commands the enumeration verb rides on; either can be the round-trip that went silent. */ + val ENUMERATION_OPCODES = listOf( + ConfigKeySweep.START_DEVICE_CONFIG_KEY_EXCHANGE_CMD, + ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD, + ) + } + /** 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 } @@ -390,6 +406,12 @@ class DeviceConfigReadProbeReport( var stopReason: String? = null private set + /** + * Per-verb reply window, in seconds, recorded by [noteTimeout]. The verdict names the window a verb + * went silent through instead of converting that silence into a claim about the firmware. + */ + private val silenceWindow = mutableMapOf() + private var phase = 0 // 0 enumerate, 1 discovery, 2 cross, 3 known keys, 4 candidates, 5 done private var cursor = 0 private var enumPhase = 0 // 0 send 115, 1 send 116 repeatedly, 2 done @@ -672,8 +694,13 @@ class DeviceConfigReadProbeReport( /** * Record the strap answering nothing at all within the per-step window. The verb is marked silent, * which retires it — one no-reply must not cost another twenty timeouts. + * + * The window is kept, not just printed, because the verdict has to be able to say how long nothing + * came back for. Silence is not the firmware refusing; it does not even establish that a frame was + * transmitted (see the header), so it can never be reported as what the firmware serves. */ fun noteTimeout(step: Step, seconds: Int) { + silenceWindow[step.opcode] = seconds setStatus(VerbStatus.SILENT, step.opcode) _trace.add("${opcodeLabel(step.opcode)} key=\"${step.key}\" → no COMMAND_RESPONSE within ${seconds}s") } @@ -752,7 +779,34 @@ class DeviceConfigReadProbeReport( /** Entries the walk actually served, decoded or not. */ private val enumerationWalked: Int get() = _enumeratedKeys.size + enumerationSkipped - /** One-line summary of what the probe established. */ + /** + * How one verb ended, worded to exactly the evidence behind it. See the file header for why the + * statuses are not interchangeable; the short version is that only UNSUPPORTED is the firmware saying + * it does not serve the verb, so only UNSUPPORTED speaks for the firmware. INCONCLUSIVE is the strap + * ANSWERING and declining the request, which is evidence the verb exists. + * + * [opcodes] is every command that can carry the verb, because the enumeration verb spans 115 and 116 + * and either one can be the round-trip that went silent. + */ + private fun outcome(status: VerbStatus, opcodes: List): String = when (status) { + VerbStatus.UNTRIED -> "not asked" + VerbStatus.ANSWERED -> "answered" + VerbStatus.UNSUPPORTED -> "refused by firmware (UNSUPPORTED)" + VerbStatus.INCONCLUSIVE -> "replied but declined the request — unconfirmed" + VerbStatus.SILENT -> opcodes.firstNotNullOfOrNull { silenceWindow[it] } + ?.let { "served no reply in ${it}s — unconfirmed" } + ?: "served no reply — unconfirmed" + VerbStatus.UNDECODABLE -> "replied but the frame did not decode — unconfirmed" + } + + /** + * One-line summary of what the probe established. + * + * "not served by this firmware" is a claim about the firmware, so it is made in exactly one case: the + * firmware refused BOTH value verbs itself. Every other run in which nothing answered reports each + * verb against the evidence that verb produced, because two timeouts, one refusal plus one timeout, + * and a reply that failed to decode are three different findings, not one. + */ val verdict: String get() { val found = newKeysFound @@ -800,15 +854,17 @@ class DeviceConfigReadProbeReport( } val answered = listOf(featureFlagVerb, deviceConfigVerb).count { it == VerbStatus.ANSWERED } if (answered == 0) { - val both = - "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" - if (featureFlagVerb == VerbStatus.UNSUPPORTED || deviceConfigVerb == VerbStatus.UNSUPPORTED) { - return "$both — rejected as UNSUPPORTED" - } - if (featureFlagVerb == VerbStatus.SILENT && deviceConfigVerb == VerbStatus.SILENT) { - return "$both — no reply to either" + if (featureFlagVerb == VerbStatus.UNSUPPORTED && deviceConfigVerb == VerbStatus.UNSUPPORTED) { + // The one run that supports the strong sentence. It names only the two verbs it speaks + // about; 115/116's own status is in the Verbs table and is not claimed here. + return "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this " + + "firmware — rejected as UNSUPPORTED" } - return both + val en = outcome(enumerationVerb, ENUMERATION_OPCODES) + val ff = outcome(featureFlagVerb, listOf(DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD)) + val dc = outcome(deviceConfigVerb, listOf(DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD)) + return "no read verb answered — device-config enumerate(115/116) $en; " + + "GET_FF_VALUE(128) $ff; GET_DEVICE_CONFIG_VALUE(121) $dc" } val asked = candidateReadings.size if (asked == 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 32101b5481..1f0dbd7c9a 100644 --- a/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt +++ b/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt @@ -411,6 +411,143 @@ class DeviceConfigReadProbeTest { assertTrue(report.render().contains("(none — no reply to 115)")) } + // ---- The verdict says only what the run established ---- + + /** + * Every verb timing out is three timeouts, not a finding about firmware. The send path can return + * without transmitting at all — no command characteristic, or a 5/MG allowlist that does not carry + * the opcode — so a run in which nothing reached the strap must not print a claim about what the + * firmware serves. + */ + @Test + fun aWhollySilentRunNeverClaimsAFirmwareBehaviour() { + val report = smallReport() + while (true) { val step = report.nextStep() ?: break; report.noteTimeout(step, 8) } + assertEquals(DeviceConfigReadProbeReport.VerbStatus.SILENT, report.enumerationVerb) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.SILENT, report.featureFlagVerb) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.SILENT, report.deviceConfigVerb) + assertEquals( + "no read verb answered — device-config enumerate(115/116) served no reply in 8s " + + "— unconfirmed; GET_FF_VALUE(128) served no reply in 8s — unconfirmed; " + + "GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed", + report.verdict, + ) + assertFalse( + "silence is not the firmware answering — it is not even proof a frame was sent", + report.render().contains("served by this firmware"), + ) + } + + /** + * One value verb refused and the other timed out: the refusal belongs to the verb that was refused. + * The old `||` printed "neither … is served by this firmware — rejected as UNSUPPORTED" over a 121 + * that was never refused, only never heard from. + */ + @Test + fun oneRefusalIsNotGeneralisedToTheVerbThatWasNeverHeardFrom() { + val report = smallReport() + val s115 = report.nextStep()!! + assertEquals(115, s115.opcode) + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) + + val s128 = report.nextStep()!! + assertEquals(DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD, s128.opcode) + report.noteReply(DeviceConfigReadProbe.ValueResponse(3, byteArrayOf(0)), s128) + + val s121 = report.nextStep()!! + assertEquals(DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD, s121.opcode) + report.noteTimeout(s121, 8) + + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, report.featureFlagVerb) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.SILENT, report.deviceConfigVerb) + assertEquals( + "no read verb answered — device-config enumerate(115/116) refused by firmware " + + "(UNSUPPORTED); GET_FF_VALUE(128) refused by firmware (UNSUPPORTED); " + + "GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed", + report.verdict, + ) + assertFalse( + "one refusal does not speak for the other verb", + report.render().contains("neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121)"), + ) + } + + /** Both value verbs refused BY THE FIRMWARE is the one run that supports the strong sentence. */ + @Test + fun bothValueVerbsRefusedKeepsTheStrongSentence() { + val report = smallReport() + report.nextStep()!! + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) + repeat(2) { + val step = report.nextStep()!! + report.noteReply(DeviceConfigReadProbe.ValueResponse(3, byteArrayOf(0)), step) + } + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, report.featureFlagVerb) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, report.deviceConfigVerb) + assertEquals( + "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this " + + "firmware — rejected as UNSUPPORTED", + report.verdict, + ) + } + + /** + * An undecodable reply is affirmative evidence the strap DID transmit, so "not served by this + * firmware" states the opposite of what the run observed. + */ + @Test + fun anUndecodableReplyIsNotReportedAsUnserved() { + val report = smallReport() + report.noteFailure(DeviceConfigReadProbe.ParseFailure.CRC, report.nextStep()!!) + report.noteFailure(DeviceConfigReadProbe.ParseFailure.ENVELOPE, report.nextStep()!!) + report.noteFailure(DeviceConfigReadProbe.ParseFailure.TRUNCATED, report.nextStep()!!) + + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNDECODABLE, report.featureFlagVerb) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNDECODABLE, report.deviceConfigVerb) + assertEquals( + "no read verb answered — device-config enumerate(115/116) replied but the frame " + + "did not decode — unconfirmed; GET_FF_VALUE(128) replied but the frame did not " + + "decode — unconfirmed; GET_DEVICE_CONFIG_VALUE(121) replied but the frame did " + + "not decode — unconfirmed", + report.verdict, + ) + assertFalse(report.render().contains("served by this firmware")) + } + + /** + * An INCONCLUSIVE enumeration is the strap ANSWERING and declining the request — evidence the verb + * exists. It must never be worded like a verb that was never heard from. + */ + @Test + fun anInconclusiveEnumerationIsReportedAsAReplyNotAsSilence() { + val report = smallReport() + report.nextStep()!! + report.noteEnumerationStart(startReply(enumStart(0, 0, 0))) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.INCONCLUSIVE, report.enumerationVerb) + while (true) { val step = report.nextStep() ?: break; report.noteTimeout(step, 8) } + + assertEquals( + "no read verb answered — device-config enumerate(115/116) replied but declined " + + "the request — unconfirmed; GET_FF_VALUE(128) served no reply in 8s — " + + "unconfirmed; GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed", + report.verdict, + ) + assertFalse(report.render().contains("served by this firmware")) + } + + /** + * A probe that ended before any verb went out says so, rather than reporting an empty run as a + * finding about the firmware. + */ + @Test + fun aProbeThatAskedNothingClaimsNothing() { + assertEquals( + "no read verb answered — device-config enumerate(115/116) not asked; " + + "GET_FF_VALUE(128) not asked; GET_DEVICE_CONFIG_VALUE(121) not asked", + smallReport().verdict, + ) + } + @Test fun anUndecodableEnumerationReplyRetiresIt() { val report = smallReport()