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 a238e2a82c..e4f4f8878d 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift @@ -19,24 +19,31 @@ 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**, -/// **silent**, or **undecodable** — 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. /// -/// Those four are not interchangeable, and the verdict never treats them as such. **Only UNSUPPORTED is -/// the firmware's own answer**, so only UNSUPPORTED supports the sentence "this firmware does not serve -/// that verb". A timeout is a fact about one run and not even proof a frame reached the strap — +/// 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. +/// +/// "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 is affirmative evidence the -/// strap DID transmit, which is the opposite of "not served". Both are reported as **unconfirmed**. +/// 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**. /// -/// 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. +/// 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 /// @@ -87,9 +94,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. @@ -106,8 +119,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 @@ -115,26 +134,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] { @@ -183,6 +182,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) } @@ -270,46 +275,67 @@ 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. 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). @@ -326,14 +352,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) } } @@ -344,8 +377,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 @@ -353,9 +386,32 @@ 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 + /// 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? + 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 @@ -365,17 +421,23 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// 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 - 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 @@ -385,15 +447,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 } @@ -403,12 +469,33 @@ 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" + enumerationTruncated = true + 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, @@ -419,45 +506,149 @@ 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. + /// + /// 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) { + if let code = r.resultCode, code != 1 { + enumPhase = 2 + 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) →" + 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 + enumerationReachedEnd = true + 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) @@ -492,10 +683,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 } } @@ -503,78 +700,269 @@ 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)" + } } - /// How one verb ended, worded to exactly the evidence behind it. See the file header for why the four + /// 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 + } + + /// 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 } + + /// 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 - /// answering, so only `unsupported` speaks for the firmware. - private func outcome(_ status: VerbStatus, for opcode: UInt8) -> String { + /// 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 .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 = silenceWindow[opcode] else { return "served no reply — unconfirmed" } + 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" + 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 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 that used to print as the same sentence. + /// 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: ", "))" + } + 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 if answered == 0 { 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" } - let ff = outcome(featureFlagVerb, for: DeviceConfigReadProbe.getFeatureFlagValueCmd) - let dc = outcome(deviceConfigVerb, for: DeviceConfigReadProbe.getDeviceConfigValueCmd) - return "no read verb answered — GET_FF_VALUE(128) \(ff); GET_DEVICE_CONFIG_VALUE(121) \(dc)" + 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 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 { + // 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" } /// 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 .inconclusive: + sb += " (none — 115 replied but declined to start a walk; the namespace was never listed)\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 30cad0e336..1da69c0d9f 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 0151818b65..de98f0d33f 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,296 +221,672 @@ 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) - // The one run that supports the strong sentence: the firmware itself refused both verbs. - XCTAssertEqual(report.verdict, - "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this " - + "firmware — rejected as UNSUPPORTED") - XCTAssertTrue(report.render().contains("neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121)")) + return r } - /// Two timeouts are two timeouts. `BLEManager.send` returns without transmitting when there is no - /// `cmdCharacteristic` and again when the 5/MG allowlist 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 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") } - XCTAssertNil(report.nextStep()) + 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)")) + } + + // 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 — GET_FF_VALUE(128) served no reply in 8s — unconfirmed; " + "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") - let text = report.render() - XCTAssertFalse(text.contains("served by this firmware"), + XCTAssertFalse(report.render().contains("served by this firmware"), "silence is not the firmware answering — it is not even proof a frame was sent") - XCTAssertFalse(text.contains("UNSUPPORTED"), "nothing was refused; nothing replied at all") - XCTAssertTrue(text.contains("no COMMAND_RESPONSE within 8s")) - XCTAssertTrue(text.contains("(none — the verb that would carry them did not answer)")) } - /// One 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. + /// 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 = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, - candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) + 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, 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, 121) + XCTAssertEqual(s121.opcode, DeviceConfigReadProbe.getDeviceConfigValueCmd) report.noteTimeout(for: s121, seconds: 8) - XCTAssertNil(report.nextStep()) XCTAssertEqual(report.featureFlagVerb, .unsupported) XCTAssertEqual(report.deviceConfigVerb, .silent) XCTAssertEqual(report.verdict, - "no read verb answered — GET_FF_VALUE(128) refused by firmware (UNSUPPORTED); " + "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 = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, candidateKeys: []) + 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(.crc, for: s128) + report.noteFailure(.envelope, for: s128) guard let s121 = report.nextStep() else { return XCTFail("121") } - report.noteFailure(.envelope, for: s121) + report.noteFailure(.truncated, for: s121) XCTAssertEqual(report.featureFlagVerb, .undecodable) XCTAssertEqual(report.deviceConfigVerb, .undecodable) XCTAssertEqual(report.verdict, - "no read verb answered — " - + "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") + "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")) } - /// A probe that ended before either verb went out says so, rather than reporting an empty run as a + /// 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 = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, candidateKeys: []) - XCTAssertEqual(report.featureFlagVerb, .untried) - XCTAssertEqual(report.deviceConfigVerb, .untried) + let report = smallReport() XCTAssertEqual(report.verdict, - "no read verb answered — GET_FF_VALUE(128) not asked; " - + "GET_DEVICE_CONFIG_VALUE(121) not asked") + "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 testAnAnsweringFeatureFlagVerbWalksTheFifteenRemainingKnownFlags() { - 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) + 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)") + } - 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) + // 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() } - 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) + let text = report.render() + 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) + } + + /// 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, + 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(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") + XCTAssertLessThanOrEqual(report.steps, DeviceConfigReadProbe.maxSteps) + XCTAssertNotNil(report.stopReason) } - 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)") + /// 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). + /// + /// `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) } + let record = control == 1 ? echoRecord(step.key, value: 0x32) : [] + report.noteReply(.init(resultCode: control, record: record), for: step) + } + return (report, nil) } - 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") + // 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") } - // 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") + /// 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)") } - /// 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". - 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. + // 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") } - 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: 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") } - 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") + 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.noteReply(r2, for: s2) + _ = 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.noteReply(.init(resultCode: 1, record: echoRecord("hr_ch_switching", value: 0x32, lead: [0x01])), for: 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 + /// either side and a wording drift fails here rather than in a user's log. + func testGoldenReportIsByteIdenticalAcrossPlatforms() { + 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))) + guard let s2 = report.nextStep() else { return XCTFail("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(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 be009f1544..1ead5c3a90 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. */ @@ -2853,13 +2861,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 @@ -3399,13 +3410,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() } @@ -3428,7 +3440,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) { @@ -3446,8 +3465,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 } @@ -3464,16 +3486,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 14042468ee..7c32f0cbc1 100644 --- a/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt +++ b/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt @@ -25,23 +25,31 @@ 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**, **silent**, or - * **undecodable** — 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. * - * Those four are not interchangeable, and the verdict never treats them as such. **Only UNSUPPORTED is - * the firmware's own answer**, so only UNSUPPORTED supports the sentence "this firmware does not serve - * that verb". A timeout is a fact about one run and not even proof a frame reached the strap — the send + * 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. + * + * "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 is affirmative evidence the strap DID - * transmit, which is the opposite of "not served". Both are reported as **unconfirmed**. + * 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**. * - * 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. + * 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 * @@ -83,9 +91,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. */ @@ -97,37 +113,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 @@ -163,6 +161,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) @@ -256,35 +260,67 @@ 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, ) { + 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 { 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"), + + /** 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"), + /** A reply arrived but could not be decoded (CRC, envelope, or a short record). */ UNDECODABLE("undecodable"), } @@ -298,7 +334,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 @@ -308,12 +348,54 @@ 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 + + /** 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 + + /** `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]. */ @@ -330,9 +412,12 @@ class DeviceConfigReadProbeReport( */ private val silenceWindow = mutableMapOf() - 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() /** @@ -344,16 +429,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 } @@ -363,11 +452,30 @@ 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" + } + enumerationTruncated = true + 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, @@ -382,53 +490,189 @@ 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. + * + * 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) { + 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=${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) →" + 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 + enumerationReachedEnd = true + _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) @@ -466,35 +710,90 @@ 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 + } + + /** + * 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 + /** - * How one verb ended, worded to exactly the evidence behind it. See the file header for why the four - * statuses are not interchangeable; the short version is that only UNSUPPORTED is the firmware - * answering, so only UNSUPPORTED speaks for the firmware. + * 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, opcode: Int): String = when (status) { + private fun outcome(status: VerbStatus, opcodes: List): String = when (status) { VerbStatus.UNTRIED -> "not asked" VerbStatus.ANSWERED -> "answered" VerbStatus.UNSUPPORTED -> "refused by firmware (UNSUPPORTED)" - VerbStatus.SILENT -> silenceWindow[opcode] + 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" @@ -504,73 +803,238 @@ class DeviceConfigReadProbeReport( * 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 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 that used to print as the same sentence. + * 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 + if (found.isNotEmpty()) { + 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 } if (answered == 0) { 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" } - val ff = outcome(featureFlagVerb, DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD) - val dc = outcome(deviceConfigVerb, DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD) - return "no read verb answered — GET_FF_VALUE(128) $ff; GET_DEVICE_CONFIG_VALUE(121) $dc" + 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 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" } - return "$answered of 2 read verbs answered; read $named config value(s)" + 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" } /** 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.INCONCLUSIVE -> + " (none — 115 replied but declined to start a walk; the namespace was never listed)\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 31133d08c1..0a5fa39867 100644 --- a/android/app/src/main/java/com/noop/protocol/Enums.kt +++ b/android/app/src/main/java/com/noop/protocol/Enums.kt @@ -226,6 +226,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 93eef6ded4..882c0fb919 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 91e7c20be8..1f0dbd7c9a 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,97 +267,227 @@ 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) - // The one run that supports the strong sentence: the firmware itself refused both verbs. + 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 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 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( - "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this " + - "firmware — rejected as UNSUPPORTED", - rep.verdict, + "116 must not be asked once 115 refused", + DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD, + s2.opcode, ) - assertTrue(rep.render().contains("neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121)")) + assertEquals(DeviceConfigReadProbeReport.Group.DISCOVERY, s2.group) + } + + /** 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)")) } + // ---- The verdict says only what the run established ---- + /** - * Two timeouts are two timeouts. The send path returns without transmitting when the command - * characteristic is missing and again when the 5/MG allowlist does not carry the opcode, so a run in - * which nothing reached the strap must not print a claim about what the firmware serves. + * 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 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) + 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 — GET_FF_VALUE(128) served no reply in 8s — unconfirmed; " + + "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", - rep.verdict, + report.verdict, ) - val text = rep.render() assertFalse( "silence is not the firmware answering — it is not even proof a frame was sent", - text.contains("served by this firmware"), + report.render().contains("served by this firmware"), ) - assertFalse("nothing was refused; nothing replied at all", text.contains("UNSUPPORTED")) - assertTrue(text.contains("no COMMAND_RESPONSE within 8s")) - assertTrue(text.contains("(none — the verb that would carry them did not answer)")) } /** - * One 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. + * 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 rep = report() - val s128 = rep.nextStep()!! - assertEquals(128, s128.opcode) - rep.noteReply(DeviceConfigReadProbe.ValueResponse(3, byteArrayOf(0)), s128) - val s121 = rep.nextStep()!! - assertEquals(121, s121.opcode) - rep.noteTimeout(s121, 8) - - assertNull(rep.nextStep()) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, rep.featureFlagVerb) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.SILENT, rep.deviceConfigVerb) + 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 — GET_FF_VALUE(128) refused by firmware (UNSUPPORTED); " + + "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", - rep.verdict, + report.verdict, ) assertFalse( "one refusal does not speak for the other verb", - rep.render().contains("neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121)"), + 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, ) } @@ -360,219 +497,529 @@ class DeviceConfigReadProbeTest { */ @Test fun anUndecodableReplyIsNotReportedAsUnserved() { - val rep = report(candidates = emptyList()) - rep.noteFailure(DeviceConfigReadProbe.ParseFailure.CRC, rep.nextStep()!!) - rep.noteFailure(DeviceConfigReadProbe.ParseFailure.ENVELOPE, rep.nextStep()!!) + 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, rep.featureFlagVerb) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNDECODABLE, rep.deviceConfigVerb) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNDECODABLE, report.featureFlagVerb) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNDECODABLE, report.deviceConfigVerb) assertEquals( - "no read verb answered — " + - "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", - rep.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", + report.verdict, ) - assertFalse(rep.render().contains("served by this firmware")) + assertFalse(report.render().contains("served by this firmware")) } /** - * A probe that ended before either verb went out says so, rather than reporting an empty run as a + * 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() { - val rep = report(candidates = emptyList()) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNTRIED, rep.featureFlagVerb) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNTRIED, rep.deviceConfigVerb) assertEquals( - "no read verb answered — GET_FF_VALUE(128) not asked; GET_DEVICE_CONFIG_VALUE(121) not asked", - rep.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", + smallReport().verdict, ) } @Test - fun anAnsweringFeatureFlagVerbWalksTheFifteenRemainingKnownFlags() { - val rep = report() - val s128 = rep.nextStep()!! - rep.noteReply( - DeviceConfigReadProbe.ValueResponse(1, echoRecord(s128.key, 0x32, byteArrayOf(0x01))), s128, + 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 ---- + + /** 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 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, ) - val s121 = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(3, byteArrayOf(0)), s121) + assertTrue(report.render().contains("GET_DEVICE_CONFIG_VALUE(121) serves BOTH namespaces.")) + } - val flagSteps = mutableListOf() - val candidateSteps = mutableListOf() + // ---- 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). + * + * [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 = 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, - ) + val step = report.nextStep() ?: return report to null + if (step.group == DeviceConfigReadProbeReport.Group.CANDIDATE) return report to step + val record = if (control == 1) echoRecord(step.key, 0x32) else ByteArray(0) + report.noteReply(valueReply(control, record), step) } - assertEquals(flagKeys.drop(1), flagSteps) - assertEquals(DeviceConfigReadProbe.OXYGEN_CANDIDATE_KEYS, candidateSteps) - assertEquals(2 + 15 + DeviceConfigReadProbe.OXYGEN_CANDIDATE_KEYS.size, rep.steps) } - @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) + // ---- The oracle's calibration control (a FAILURE only means "no such key" once a known key passed) ---- - val c = rep.nextStep()!! - assertEquals(DeviceConfigReadProbeReport.Group.CANDIDATE, c.group) - assertEquals(121, c.opcode) - assertEquals("enable_spo2", c.key) + /** 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 thePlanIsCappedEvenWithAnAbsurdKeyList() { - val many = (0 until 500).map { "key_$it" } - val rep = report(family = DeviceFamily.WHOOP4, flags = many, candidates = many) - var seen = 0 + fun theCleanNegativeSurvivesWhenTheControlAnswered() { + val (report, first) = driveToCandidates(2) + var step = first!! 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) + report.noteReply(valueReply(0, ByteArray(0)), step) + step = report.nextStep() ?: break } - assertEquals(DeviceConfigReadProbe.MAX_STEPS, seen) - assertEquals("safety cap of 64 round-trips reached", rep.stopReason) + 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 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 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 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 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) } - // MARK: - Report + // ---- 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 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 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 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"), - ) + fun anEnumerationWhoseNamesAllFailedOurParserBlamesOurParserNotTheStrap() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(1, 10, 2))) + for (i in 1..2) { + report.nextStep() assertTrue( - "$k would be truncated by the 32-byte name field", - k.toByteArray(Charsets.UTF_8).size <= DeviceConfigReadProbe.NAME_FIELD_BYTES, + 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")) } - /** - * 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". - */ + /** 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 + * failure. */ + @Test + 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 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") }) + } + + /** 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 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 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) + } + + // ---- 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 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) - - 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) - - val s3 = rep.nextStep()!! - rep.noteReply( - DeviceConfigReadProbe.ValueResponse(1, echoRecord("hr_ch_switching", 0x32, byteArrayOf(0x01))), s3, - ) - - 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()) + 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()) + } + + 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. + +Verdict: asked 2 candidate key name(s); this firmware has none of them (a clean negative) + +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. + +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 85be882044..c34d441706 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -567,41 +567,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