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..8522ed66b7 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift @@ -19,24 +19,23 @@ import Foundation /// /// ## What this establishes, and the honest failure case /// -/// **Both target opcodes may simply not be implemented in firmware.** The repo's own protocol table -/// (`Resources/whoop_protocol.json`, `CommandNumber`) names 121 `GET_DEVICE_CONFIG_VALUE` and 128 -/// `GET_FF_VALUE`, but a name in a table is not a served verb: opcode 96 (`enterHighFreqHistoricalMode`) -/// is a standing example of a number the table carries that nothing in the wild sends. So the probe's -/// PRIMARY deliverable is a clean verdict per verb — **answered**, **rejected as UNSUPPORTED**, -/// **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 — -/// `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 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. /// -/// 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 +86,15 @@ public enum DeviceConfigReadProbe { /// This probe never sends it. public static let setFeatureFlagValueCmd: UInt8 = 120 - /// The complete set of opcodes this probe may put on the wire. The BLE send path admits these two and - /// **only** these two while a probe is in flight; `isReadOnlyOpcode` is the predicate it asks. - public static let readOnlyOpcodes: Set = [getDeviceConfigValueCmd, getFeatureFlagValueCmd] + /// The complete set of opcodes this probe may put on the wire: the two VALUE reads above, plus the two + /// DEVICE-CONFIG ENUMERATION verbs the probe now tries first (`ConfigKeySweep`, 115/116 — the + /// structural twins of the 117/118 pair #872 shipped and a real strap answered read-only). The BLE + /// send path admits these four and **only** these four while a probe is in flight; `isReadOnlyOpcode` + /// is the predicate it asks, and unit tests prove it rejects 119, 120 and every other opcode. + public static let readOnlyOpcodes: Set = [ + getDeviceConfigValueCmd, getFeatureFlagValueCmd, + ConfigKeySweep.startDeviceConfigKeyExchangeCmd, ConfigKeySweep.sendNextDeviceConfigCmd, + ] /// The config WRITE verbs, which this probe must never emit. Kept as a named set so the read-only /// contract is testable as a property of the allowlist rather than as a claim in a comment. @@ -106,8 +111,19 @@ 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. + /// Hard ceiling on round-trips in one probe. Raised from 128 when the sweep began asking every + /// candidate through EVERY answering verb: the worst case is enumeration + 2 discovery + 2 cross + + /// the known-key reads + 2 × the catalogue, which passes 128 and would otherwise have been silently + /// truncated by the cap — reported as "safety cap reached", but still a short sweep presented as a + /// finished one. + public static let maxSteps = 320 /// The one device-config key NOOP already knows a real strap accepts: the Broadcast-HR flag written /// via `SET_DEVICE_CONFIG_VALUE` and hardware-validated in #181. Used as the discovery key for opcode @@ -115,26 +131,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 +179,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) } @@ -209,12 +211,44 @@ public enum DeviceConfigReadProbe { /// The value byte, reported ONLY when the strap echoed `key` in a 32-byte NUL-padded name field /// and the record extends one byte past it — the same `[name 32][value]` layout the SET bodies /// use. nil means "no value claimed", never "value is zero". + /// + /// **First byte only.** Values are not all one byte (see `stringValue(for:)`); this stays as it is + /// because every caller that reports a single flag character wants exactly this, and widening it + /// would silently change what the sweep prints. public func value(for key: String) -> UInt8? { guard let off = echoOffset(of: key) else { return nil } let valueIndex = off + DeviceConfigReadProbe.nameFieldBytes guard valueIndex < record.count else { return nil } return record[valueIndex] } + + /// The value as the strap actually stores it — the WHOLE NUL-terminated ASCII string after the + /// echoed name field, not just its first byte. + /// + /// Device-config values are **not** all single characters. A WHOOP 5 MG's own 115/116 enumeration + /// listed `max_collection_backlog`, whose value reads `"0.0"` — three characters. `value(for:)` + /// would report that as `'0'` and quietly lose the rest, which is fine for a flag and wrong for + /// anything else, so any caller comparing a value against what it asked for must use this. + /// + /// Stops at the first NUL, which is what keeps the puffin envelope's 4-byte-boundary padding out + /// of the answer (the same reasoning `value(for:)` relies on for reading the byte after the name + /// rather than the last byte of the record). Returns nil — "no value claimed", never "empty" — when + /// the key was not echoed, when nothing follows the name field, or when what follows is not + /// printable ASCII, since a non-ASCII run is not a value this layout can honestly claim to have read. + public func stringValue(for key: String) -> String? { + guard let off = echoOffset(of: key) else { return nil } + let start = off + DeviceConfigReadProbe.nameFieldBytes + guard start < record.count else { return nil } + var bytes: [UInt8] = [] + for i in start.. = [] - public init(family: DeviceFamily, knownFlagKeys: [String], candidateKeys: [String]) { + public init(family: DeviceFamily, knownFlagKeys: [String], batch: ConfigKeySweep.Batch, + forceCandidateSweep: Bool = false) { self.family = family self.knownFlagKeys = knownFlagKeys - self.candidateKeys = candidateKeys + self.batch = batch + self.forceCandidateSweep = forceCandidateSweep } + /// Whether this run will ask the guessed names at all. + /// + /// By default the sweep is a FALLBACK: a strap that enumerated its own device-config keys has already + /// answered the question guessing was for, so the sweep is skipped. + /// + /// That default is right for the device-config namespace and **incomplete as a general claim**, which + /// is why `forceCandidateSweep` exists. Enumeration reports the keys the firmware holds; a key it + /// would accept but has never been given a value for need not be among them, and the oracle cannot + /// separate that case from "no such key" because both answer `FAILURE(0)`. A successful enumeration is + /// therefore evidence about what the strap HAS, not proof of what it would ACCEPT — and it says + /// nothing at all about the FEATURE-FLAG namespace, which is where most of the catalogue is aimed. + /// + /// So the forced run stays available, and stays explicit: it costs one round-trip per catalogue name, + /// which is not something to spend by default. + public var runsCandidateSweep: Bool { forceCandidateSweep || enumeratedKeys.isEmpty } + // MARK: - Plan /// The next round-trip to send, or nil when the probe is done. Called after the previous reply has @@ -385,15 +477,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 +499,32 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { return nil } - /// One candidate step from the current phase, or nil when that phase is exhausted. - private func stepInCurrentPhase() -> Step? { + /// One step from the current phase, or nil when that phase is exhausted. + private mutating func stepInCurrentPhase() -> Step? { switch phase { case 0: - // Discovery: one round-trip per verb, each against a key that verb has a reason to know. - // 128 gets a flag NOOP writes; 121 gets the Broadcast-HR key, hardware-validated in #181. + // Ask the strap to list its own device-config keys. 115 once; then 116 until the strap says + // stop, exactly as the 117/118 walk does. + switch enumPhase { + case 0: + return Step(opcode: ConfigKeySweep.startDeviceConfigKeyExchangeCmd, key: "", + group: .enumerate) + case 1: + guard enumSteps < ConfigKeySweep.maxEnumerationSteps else { + stopReason = stopReason + ?? "device-config enumeration hit its cap of \(ConfigKeySweep.maxEnumerationSteps) entries; the rest of the plan still ran" + enumPhase = 2 + return nil + } + enumSteps += 1 + return Step(opcode: ConfigKeySweep.sendNextDeviceConfigCmd, key: "", group: .enumerate) + default: + return nil + } + case 1: + // Discovery: one round-trip per VALUE verb, each against a key that verb has a reason to know. + // 128 gets a flag NOOP writes; 121 gets the Broadcast-HR key NOOP has written since #181, so a + // FAILURE there is evidence about the VERB, not about the key. let plan: [Step] = [ Step(opcode: DeviceConfigReadProbe.getFeatureFlagValueCmd, key: knownFlagKeys.first ?? DeviceConfigReadProbe.deviceConfigDiscoveryKey, @@ -419,45 +535,156 @@ 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 — + // unless this run was explicitly asked to sweep anyway (see `runsCandidateSweep` for why a + // successful enumeration does not close the question). + // + // EVERY candidate goes through EVERY answering verb, not through the one its `namespace` + // field guesses. That field is an author's expectation, and the namespaces are now PROVEN + // SEPARATE on hardware: 128 asked for a device-config key answers FAILURE, and 121 asked for a + // feature-flag key answers FAILURE. So a candidate that really is a device-config key, asked + // only through 128, comes back FAILURE and is indistinguishable from "no such key" — which + // would have made a negative sweep worthless for exactly the names it most needed to settle. + // Asking both costs one extra round-trip per name and is what lets a negative be called clean. + guard runsCandidateSweep else { return nil } + let verbs = candidateVerbs + guard !verbs.isEmpty else { return nil } + let idx = cursor / verbs.count + guard idx < batch.candidates.count else { return nil } + let candidate = batch.candidates[idx] + return Step(opcode: verbs[cursor % verbs.count], key: candidate.key, group: .candidate, + derivation: candidate.derivation) default: return nil } } - /// The 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 VALUE verbs a candidate name is asked through — every one that answered, in a stable order + /// (121 before 128) so the plan is deterministic. Empty when neither answered, which retires the sweep. + private var candidateVerbs: [UInt8] { + var verbs: [UInt8] = [] + if deviceConfigVerb == .answered { verbs.append(DeviceConfigReadProbe.getDeviceConfigValueCmd) } + if featureFlagVerb == .answered { verbs.append(DeviceConfigReadProbe.getFeatureFlagValueCmd) } + return verbs } - /// The 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 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 ask a key of, or nil when neither VALUE verb answered. + /// + /// A verb SHOWN in this same run to serve the other namespace too is preferred for everything — fewer + /// moving parts, and the evidence is from this run rather than an assumption. Otherwise each namespace + /// uses its own verb, falling back to the other one as a look worth taking. + private func verb(for namespace: ConfigKeySweep.Namespace) -> UInt8? { + if deviceConfigVerbOnFlagKey == .exists, deviceConfigVerb == .answered { + return DeviceConfigReadProbe.getDeviceConfigValueCmd + } + if featureFlagVerbOnDeviceConfigKey == .exists, featureFlagVerb == .answered { + return DeviceConfigReadProbe.getFeatureFlagValueCmd + } + let ff = featureFlagVerb == .answered ? DeviceConfigReadProbe.getFeatureFlagValueCmd : nil + let dc = deviceConfigVerb == .answered ? DeviceConfigReadProbe.getDeviceConfigValueCmd : nil + return namespace == .featureFlag ? (ff ?? dc) : (dc ?? ff) } // MARK: - Notes - /// Record one decoded reply. + /// Record the `START_DEVICE_CONFIG_KEY_EXCHANGE` reply. An implausible count is reported but never + /// trusted as a loop bound — the walk is bounded by `ConfigKeySweep.maxEnumerationSteps` and by the + /// strap's own end marker. + public mutating func noteEnumerationStart(_ r: FeatureFlagProbe.StartResponse) { + enumeratedCount = r.count + if r.resultCode == 3 { + enumerationVerb = .unsupported + enumPhase = 2 + trace.append("START_DEVICE_CONFIG_KEY_EXCHANGE(115) → result=UNSUPPORTED(3) — the firmware does not serve this verb") + return + } + enumerationVerb = .answered + enumPhase = 1 + var line = "START_DEVICE_CONFIG_KEY_EXCHANGE(115) →" + if let c = r.resultCode { line += " result=\(FeatureFlagProbe.resultLabel(c))(\(c))" } + line += " revision=\(r.revision) count=\(r.count)" + if !r.countIsPlausible { line += " (implausible — walked to the strap's own end marker instead)" } + trace.append(line) + } + + /// Record one `SEND_NEXT_DEVICE_CONFIG` reply. Returns true when the walk should continue. + /// + /// Mirrors the #874 discipline on the 117/118 walk: the strap's own end marker terminates the walk, + /// but a name OUR parser declines (`isSkippable`) is counted and stepped over — one undecodable entry + /// must not throw away every key after it. + @discardableResult + public mutating func noteEnumerationNext(_ r: FeatureFlagProbe.NextResponse) -> Bool { + if r.isExhausted { + enumPhase = 2 + trace.append("SEND_NEXT_DEVICE_CONFIG(116) → end of list (index=\(r.index) validKey=\(r.validKey))") + return false + } + if r.isSkippable { + enumerationSkipped += 1 + trace.append("SEND_NEXT_DEVICE_CONFIG(116) → index=\(r.index) name did not decode — stepped over") + return true + } + if let key = r.key { + enumeratedKeys.append(key) + trace.append("SEND_NEXT_DEVICE_CONFIG(116) → index=\(r.index) key=\"\(key)\"") + } + return true + } + + /// Record one decoded VALUE reply. public mutating func noteReply(_ r: DeviceConfigReadProbe.ValueResponse, for step: Step) { setStatus(r.isUnsupported ? .unsupported : .answered, for: step.opcode) + if step.group == .crossNamespace { + if step.opcode == DeviceConfigReadProbe.getFeatureFlagValueCmd { + featureFlagVerbOnDeviceConfigKey = r.existence + } else if step.opcode == DeviceConfigReadProbe.getDeviceConfigValueCmd { + deviceConfigVerbOnFlagKey = r.existence + } + } let value = r.value(for: step.key) readings.append(Reading(group: step.group, opcode: step.opcode, key: step.key, value: value, - resultCode: r.resultCode, recordHex: r.recordHex)) + resultCode: r.resultCode, recordHex: r.recordHex, + derivation: step.derivation)) + // The candidate section lists every name it asked, so repeating a plain "unknown" in the + // transcript would double the report for no information. Anything else is always traced. + guard step.group != .candidate || r.existence != .unknown else { return } var line = "\(DeviceConfigReadProbeReport.opcodeLabel(step.opcode)) key=\"\(step.key)\"" if let c = r.resultCode { line += " → result=\(FeatureFlagProbe.resultLabel(c))(\(c))" } else { line += " →" } + line += " \(r.existence.label)" if let v = value { line += " value=\(DeviceConfigReadProbe.valueLabel(v))" } line += " record=[\(r.recordHex)]" trace.append(line) @@ -479,12 +706,7 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// Record the strap answering nothing at all within the per-step window. The verb is marked silent, /// which retires it — one no-reply must not cost another twenty timeouts. - /// - /// The window is kept, not just printed, because the verdict has to be able to say how long nothing - /// came back for. Silence is not the firmware refusing; it does not even establish that a frame was - /// transmitted (see the header), so it can never be reported as what the firmware serves. public mutating func noteTimeout(for step: Step, seconds: Int) { - silenceWindow[step.opcode] = seconds setStatus(.silent, for: step.opcode) trace.append("\(DeviceConfigReadProbeReport.opcodeLabel(step.opcode)) key=\"\(step.key)\" → no COMMAND_RESPONSE within \(seconds)s") } @@ -492,10 +714,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 +731,217 @@ 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 - /// 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 { - switch status { - case .untried: return "not asked" - case .answered: return "answered" - case .unsupported: return "refused by firmware (UNSUPPORTED)" - case .silent: - guard let seconds = silenceWindow[opcode] 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" + /// Candidate readings only. + private var candidateReadings: [Reading] { readings.filter { $0.group == .candidate } } + + /// Every key name this run proved EXISTS that NOOP did not already have — the whole point of the + /// exercise. Enumerated names count; so does any candidate the oracle confirmed. + public var newKeysFound: [String] { + var out = enumeratedKeys.filter { + !knownFlagKeys.contains($0) && $0 != DeviceConfigReadProbe.deviceConfigDiscoveryKey } + for r in candidateReadings where r.existence == .exists && !out.contains(r.key) { out.append(r.key) } + return out } /// One-line summary of what the probe established. - /// - /// "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. public var verdict: String { + if !newKeysFound.isEmpty { + return "\(newKeysFound.count) config key name(s) found that NOOP did not have: \(newKeysFound.joined(separator: ", "))" + } + // Only claim "enumeration settled it" when enumeration was in fact the whole run. A forced sweep + // asked dozens of names as well, and its clean negative is the more informative headline. + if enumerationVerb == .answered, candidateReadings.isEmpty { + return "the strap enumerated its device-config namespace and returned no key NOOP did not already have" + } let answered = [featureFlagVerb, deviceConfigVerb].filter { $0 == .answered }.count if answered == 0 { - if featureFlagVerb == .unsupported && deviceConfigVerb == .unsupported { - return "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this " - + "firmware — rejected as UNSUPPORTED" + let both = "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" + if featureFlagVerb == .unsupported || deviceConfigVerb == .unsupported { + return "\(both) — rejected as UNSUPPORTED" + } + if featureFlagVerb == .silent && deviceConfigVerb == .silent { + return "\(both) — no reply to either" } - 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)" + return both } - let named = readings.filter { $0.value != nil }.count - if named == 0 { - return "\(answered) of 2 read verbs answered, but no reply echoed its key so no value is claimed" + // Counted in NAMES, not round-trips: each name is asked through every answering verb, so the + // headline would otherwise double. A name only counts as "does not exist" when EVERY verb that + // asked it said so. + var names: [String] = [] + for r in candidateReadings where !names.contains(r.key) { names.append(r.key) } + let asked = names.count + if asked == 0 { + return "\(answered) of 2 read verbs answered; no candidate name was asked" } - return "\(answered) of 2 read verbs answered; read \(named) config value(s)" + let unknown = names.filter { key in + let rows = candidateReadings.filter { $0.key == key } + return !rows.isEmpty && rows.allSatisfy { $0.existence == .unknown } + }.count + if unknown == asked { + // A fully-negative sweep is worth more when enumeration ALSO answered: the device-config + // namespace is then fully listed and the guessed names are all refused, which is a much + // stronger negative than a sweep run against a strap that never listed anything. + return enumerationVerb == .answered + ? "asked \(asked) candidate key name(s); this firmware has none of them, and its device-config namespace enumerated in full (a clean negative)" + : "asked \(asked) candidate key name(s); this firmware has none of them (a clean negative)" + } + return "asked \(asked) candidate key name(s); \(unknown) do not exist, \(asked - unknown) inconclusive" } /// The full copyable report. public func render() -> String { let fam = family == .whoop5 ? "WHOOP 5/MG" : "WHOOP 4.0" - var sb = "#103 DEVICE-CONFIG READ PROBE — \(fam)\n" - sb += "Read-only: GET_DEVICE_CONFIG_VALUE(121) + GET_FF_VALUE(128). No value is written; " - sb += "SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path.\n" - sb += "Follow-up to the #761 enumeration probe: that one asked for key NAMES, this asks for VALUES.\n" + var sb = "#103 CONFIG KEY PROBE — \(fam)\n" + sb += "Read-only: START_DEVICE_CONFIG_KEY_EXCHANGE(115), SEND_NEXT_DEVICE_CONFIG(116), " + sb += "GET_DEVICE_CONFIG_VALUE(121), GET_FF_VALUE(128).\n" + sb += "No value is written; SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path.\n" + sb += "Oracle: result=SUCCESS(1) means the key NAME exists; result=FAILURE(0) means the firmware has no such key.\n" sb += "\nVerdict: \(verdict)\n" if let stopReason { sb += "Stopped: \(stopReason)\n" } - sb += "\nRead verbs:\n" - sb += " " + DeviceConfigReadProbe.padded("GET_FF_VALUE(128)", to: 30) + featureFlagVerb.rawValue + "\n" - sb += " " + DeviceConfigReadProbe.padded("GET_DEVICE_CONFIG_VALUE(121)", to: 30) + deviceConfigVerb.rawValue + "\n" + sb += "\nVerbs:\n" + sb += " " + DeviceConfigReadProbe.padded("device-config enumerate(115/116)", to: 34) + + enumerationVerb.rawValue + "\n" + sb += " " + DeviceConfigReadProbe.padded("GET_FF_VALUE(128)", to: 34) + featureFlagVerb.rawValue + "\n" + sb += " " + DeviceConfigReadProbe.padded("GET_DEVICE_CONFIG_VALUE(121)", to: 34) + + deviceConfigVerb.rawValue + "\n" + sb += enumerationSection() + sb += namespaceSection() sb += section(.discovery, - title: "Discovery — one round-trip per verb against a key it should know", + title: "Discovery — one round-trip per value verb against a key it should know", empty: "(none — no reply was decoded)") - sb += section(.knownFlag, - title: "Known feature-flag values (names NOOP already writes; values never read before)", - empty: "(none — the verb that would carry them did not answer)") - sb += section(.candidate, - title: "Candidate oxygen keys — GUESSES, never observed on a wire or in any table", - empty: "(none — the verb that would carry them did not answer)") + sb += section(.knownKey, + title: "Known key values (the flags NOOP writes, plus anything enumeration returned)", + empty: "(none — no value verb answered)") + sb += candidateSection() sb += "\nExchange:\n" for line in trace { sb += " " + line + "\n" } return sb } + /// The strap's own device-config key list — the result that makes guessing unnecessary. + private func enumerationSection() -> String { + var sb = "\nDevice-config keys the strap listed for itself (115/116) (\(enumeratedKeys.count)):\n" + if enumeratedKeys.isEmpty { + switch enumerationVerb { + case .unsupported: + sb += " (none — the firmware refused 115 as UNSUPPORTED)\n" + case .silent: + sb += " (none — no reply to 115)\n" + case .undecodable: + sb += " (none — the reply did not decode)\n" + case .answered: + sb += " (none — 115 answered but the walk produced no names)\n" + case .untried: + sb += " (none — not reached)\n" + } + return sb + } + for (i, key) in enumeratedKeys.enumerated() { + sb += String(format: " %2d. ", i + 1) + key + "\n" + } + if enumerationSkipped > 0 { + sb += " (\(enumerationSkipped) further entr(ies) the strap called real but whose name did not decode)\n" + } + if let c = enumeratedCount, c != enumeratedKeys.count + enumerationSkipped { + sb += " (the strap announced \(c); the walk served \(enumeratedKeys.count + enumerationSkipped))\n" + } + return sb + } + + /// Whether the two namespaces are really separate — two round-trips that shape every future sweep. + private func namespaceSection() -> String { + var sb = "\nNamespace separation:\n" + let ffLabel = featureFlagVerbOnDeviceConfigKey?.label ?? "not asked" + let dcLabel = deviceConfigVerbOnFlagKey?.label ?? "not asked" + sb += " " + DeviceConfigReadProbe.padded("128 asked for a device-config key", to: 38) + ffLabel + "\n" + sb += " " + DeviceConfigReadProbe.padded("121 asked for a feature-flag key", to: 38) + dcLabel + "\n" + switch (featureFlagVerbOnDeviceConfigKey, deviceConfigVerbOnFlagKey) { + case (.some(.exists), _): + sb += " ⇒ GET_FF_VALUE(128) serves BOTH namespaces.\n" + case (_, .some(.exists)): + sb += " ⇒ GET_DEVICE_CONFIG_VALUE(121) serves BOTH namespaces.\n" + case (.some(.unknown), .some(.unknown)): + sb += " ⇒ the namespaces are separate: neither verb sees the other's keys.\n" + default: + sb += " ⇒ inconclusive.\n" + } + return sb + } + + /// The candidate sweep, grouped by derivation, with the tested/untested arithmetic spelled out. + private func candidateSection() -> String { + let rows = candidateReadings + // NAMES, not round-trips: each name is now asked through every answering verb, so counting rows + // would report "108 asked of 54 in the catalogue". The arithmetic has to stay in the same units + // the catalogue is measured in or the untested figure goes negative and stops meaning anything. + var seen: [String] = [] + for r in rows where !seen.contains(r.key) { seen.append(r.key) } + let tested = seen.count + let total = ConfigKeySweep.catalogue.count + let untested = total - batch.start - tested + var sb = "\nCandidate key names — GUESSES, never observed on a wire or in any table" + if forceCandidateSweep { + sb += " [FULL SWEEP: asked even though enumeration succeeded]" + } + sb += " (\(tested) asked of \(total) in the catalogue" + sb += untested > 0 ? "; \(untested) untested" : "; none untested" + sb += "):\n" + if rows.isEmpty { + if !enumeratedKeys.isEmpty && !forceCandidateSweep { + sb += " (skipped — the strap enumerated its own device-config keys, so nothing needs guessing.\n" + sb += " Enumeration lists what the firmware HOLDS, not everything it would ACCEPT, and says\n" + sb += " nothing about the feature-flag namespace: re-run with the full name sweep to ask anyway.)\n" + } else if featureFlagVerb != .answered && deviceConfigVerb != .answered { + sb += " (none — no value verb answered, so no name could be asked)\n" + } else { + sb += " (none asked)\n" + } + return sb + } + // A NAME exists if ANY verb said so, and is only "does not exist" when EVERY verb that asked said + // so — the whole reason both verbs are asked. + let exists = seen.filter { key in rows.contains { $0.key == key && $0.existence == .exists } }.count + let unknown = seen.filter { key in + let asked = rows.filter { $0.key == key } + return !asked.isEmpty && asked.allSatisfy { $0.existence == .unknown } + }.count + sb += " \(exists) exist · \(unknown) do not · \(tested - exists - unknown) inconclusive" + sb += " (each name asked through \(candidateVerbs.count) verb(s))\n" + for derivation in ConfigKeySweep.Derivation.allCases { + let names = seen.filter { key in rows.contains { $0.key == key && $0.derivation == derivation } } + guard !names.isEmpty else { continue } + sb += "\n \(derivation.title) (\(names.count)):\n" + for (i, key) in names.enumerated() { + sb += String(format: " %2d. ", i + 1) + DeviceConfigReadProbe.padded(key, to: 32) + // Per-verb, so a name that answered differently on 121 and 128 is visible rather than + // collapsed into one word. + let asked = rows.filter { $0.key == key } + sb += asked.map { r in + var cell = "\(r.opcode)=\(r.existence.label)" + if let v = r.value { cell += "(" + DeviceConfigReadProbe.valueLabel(v) + ")" } + return cell + }.joined(separator: " · ") + sb += "\n" + } + } + 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/DeviceConfigWriteGate.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigWriteGate.swift new file mode 100644 index 0000000000..634ccd1434 --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigWriteGate.swift @@ -0,0 +1,351 @@ +import Foundation + +/// #891 / #103: the ONE device-config key this app may write beyond the Broadcast-HR flag — +/// `enable_raw_data_w_ecg` — and the key-aware allowlist that keeps every other key off the wire. +/// +/// ## Where the key came from +/// +/// The strap listed it itself. `START_DEVICE_CONFIG_KEY_EXCHANGE(115)` + `SEND_NEXT_DEVICE_CONFIG(116)` +/// — the read-only enumeration pair `ConfigKeySweep` builds — was answered by a WHOOP 5 MG with +/// `revision=1 count=7`, and the walk ran to a clean `index=255 validKey=false` terminator listing: +/// +/// ``` +/// sigproc_wear_detect enable_rfid max_collection_backlog +/// cont_collection_mode whoop_live_hr_in_adv_ind_pkt whoop_live_2_hrm_devices +/// enable_raw_data_w_ecg +/// ``` +/// +/// Six of those seven were unknown to this codebase. Nothing here is guessed: the names are the strap's +/// own, read off its own enumeration. +/// +/// ## Why this key, and why now +/// +/// #891 records that all three TOGGLE_LABRADOR (ECG) commands — 139, 125, 124 — answer `SUCCESS(1)` on a +/// WHOOP 5 MG and produce **zero** ECG packets in a 30-second listen. What the echoed byte those replies +/// carry actually MEANS is still open: arg-echo is refuted (SELECT_WRIST was sent 0 and answered 1) and +/// blanket payload-echo is refuted (GET_BATTERY_LEVEL sends `[0x00]` and answers 0x2F), but "read-back of +/// stored state" remains an inference — a per-opcode handler echoing a constant fits every observation so +/// far just as well. Either way the practical lesson holds: a `SUCCESS` result byte is not evidence that +/// state changed. +/// +/// On the same strap `enable_raw_data_w_ecg` reads `'0'`. A device-config key whose name pairs "raw data" +/// with "ecg", sitting at `'0'` on a strap whose ECG toggles accept and emit nothing, is the leading +/// candidate for the gate. **Whether flipping it actually produces ECG data is UNKNOWN.** A negative +/// result — gate flipped to `'1'`, read back as `'1'`, still no packets — is a publishable answer that +/// removes the leading hypothesis from #891, and is the outcome this path is built to establish either way. +/// +/// ## Why a write is safe to offer at all +/// +/// Config writes on this firmware are demonstrated, not assumed: running the existing `enable_r22_*` +/// sequence (#174) moved `enable_sig12` from `'2'` (0x32) to `'1'` (0x31), confirmed by a 121 read before +/// and after. So a `SET_DEVICE_CONFIG_VALUE(119)` write lands, and the value that comes back afterwards is +/// real rather than an echo of what was sent. +/// +/// ## Read-back is the proof, not the ack +/// +/// The write's own `COMMAND_RESPONSE` is **not** treated as evidence. #891 is the standing example of why: +/// `SELECT_WRIST` returns SUCCESS for a no-op and FAILURE for a real change, so a result byte says nothing +/// reliable about whether state moved. Every write from this path is therefore followed by a +/// `GET_DEVICE_CONFIG_VALUE(121)` read of the same key, and only the value that comes back is reported. +/// (Framing owed to @ryanbr on #891.) +/// +/// ## The allowlist is key-aware, not just opcode-aware +/// +/// Opcode 119 is shared: the Broadcast-HR flag (#181) writes through it too. An opcode-only allowlist +/// therefore cannot express "this key and no other", and before this file the 5/MG send path admitted ANY +/// device-config key while the Broadcast-HR opt-in happened to be on. `admitsSend` closes that: it parses +/// the key name out of the body and admits exactly two keys, each only while its OWN opt-in is on. The +/// five remaining enumerated keys are named in `outOfScopeKeys` and are refused unconditionally — their +/// effects are unknown and nothing here has any reason to move them. +/// +/// This is the same discipline `DeviceConfigReadProbe.isReadOnlyOpcode` established for the read probes: a +/// single pure predicate that the BLE send path itself consults, so a unit test proving the predicate +/// rejects something is proving it about the real wire path and not about a parallel copy of the rule. +/// +/// Pure: no CoreBluetooth, no I/O, no preferences. The app layer supplies the two opt-in booleans. The +/// Kotlin twin is `com.noop.protocol.DeviceConfigWriteGate` — keep them byte-identical. +public enum DeviceConfigWriteGate { + + // MARK: - Opcodes + + /// `SET_DEVICE_CONFIG_VALUE` (119 / 0x77) — writes ONE persistent device-config value. The only write + /// verb this gate ever admits, and only for the two keys below. + public static let setDeviceConfigValueCmd: UInt8 = 119 + + /// `SET_FF_VALUE` (120 / 0x78) — the FEATURE-FLAG write verb (the `enable_r22_*` sequence, #174). A + /// different namespace, and named here for exactly one reason: so `admitsSend` can be proved to refuse + /// it. Nothing on this path may ever send it. + public static let setFeatureFlagValueCmd: UInt8 = 120 + + /// `GET_DEVICE_CONFIG_VALUE` (121 / 0x79) — the read verb the mandatory post-write read-back uses. + /// Read-only; it is also in `DeviceConfigReadProbe.readOnlyOpcodes`. + public static let getDeviceConfigValueCmd: UInt8 = 121 + + // MARK: - Keys + + /// The key this file exists for. Written ONLY while the ECG-gate opt-in is on AND the strap has + /// positively attested itself a WHOOP MG (`Whoop5Variant.isMG`) — a plain 5.0 has no electrodes, so + /// the write has nothing to gate there. + public static let ecgRawDataKey = "enable_raw_data_w_ecg" + + /// The Broadcast-HR key (#181), hardware-validated since a Garmin Edge 840 paired to it. Admitted only + /// while ITS own opt-in is on — restated here so the gate can express one rule per key rather than one + /// rule per opcode. + public static let broadcastHrKey = "whoop_live_hr_in_adv_ind_pkt" + + /// The other five keys the strap's 115/116 enumeration listed. Each is refused unconditionally: their + /// effects are undocumented and unmeasured, and `max_collection_backlog` in particular reads `"0.0"`, + /// which is not even a flag. Listed rather than merely omitted so the refusal is testable and so a + /// future edit that wants one of them has to delete a line deliberately. + public static let outOfScopeKeys: [String] = [ + "sigproc_wear_detect", + "enable_rfid", + "max_collection_backlog", + "cont_collection_mode", + "whoop_live_2_hrm_devices", + ] + + /// Every device-config key the strap enumerated, in the order it served them. Reported in the UI and + /// used by tests; never used to build a write. + public static let enumeratedKeys: [String] = [ + "sigproc_wear_detect", + "enable_rfid", + "max_collection_backlog", + "cont_collection_mode", + broadcastHrKey, + "whoop_live_2_hrm_devices", + ecgRawDataKey, + ] + + // MARK: - Values + + /// ASCII `'1'` — the gate on. + public static let enabledValue: UInt8 = 0x31 + /// ASCII `'0'` — the gate off, and what a subscription-free MG reads today. + public static let disabledValue: UInt8 = 0x30 + + /// The value byte for a requested state. + public static func value(on: Bool) -> UInt8 { on ? enabledValue : disabledValue } + + /// The value byte rendered as the character the strap stores. + public static func valueString(on: Bool) -> String { on ? "1" : "0" } + + // MARK: - Body parsing + + /// Width of the key-name field in a device-config body (`Whoop5Config.deviceConfigBody` NUL-pads to + /// this). Shared with `DeviceConfigReadProbe.nameFieldBytes`. + public static let nameFieldBytes = 32 + + /// The key name carried by a `SET_DEVICE_CONFIG_VALUE` payload, or nil when the payload is not shaped + /// like one. + /// + /// The payload the send path holds is `[0x01] + deviceConfigBody(...)`: the inner b3 byte, then the + /// 32-byte NUL-padded name, then the value. A name is only returned when it is printable ASCII and the + /// remainder of the field is genuine NUL padding — so a body that is short, mis-shaped, or carrying + /// binary in the name field yields nil and is refused rather than guessed at. + public static func keyName(inSendPayload payload: [UInt8]) -> String? { + guard payload.count >= 1 + nameFieldBytes, payload[0] == 0x01 else { return nil } + let field = Array(payload[1..<(1 + nameFieldBytes)]) + var name: [UInt8] = [] + for b in field { + if b == 0 { break } + guard (0x20...0x7E).contains(b) else { return nil } + name.append(b) + } + guard !name.isEmpty else { return nil } + // Everything after the name must be NUL, or this is not a NUL-padded name field. + for i in name.count.. Bool { + switch key { + case ecgRawDataKey: return ecgGateOptIn && isMG + case broadcastHrKey: return broadcastHrOptIn + default: return false + } + } + + /// **The send allowlist itself.** True only for `SET_DEVICE_CONFIG_VALUE(119)` carrying a well-formed + /// body whose key passes `isWritableKey`. + /// + /// Every other opcode is false — explicitly including `SET_FF_VALUE(120)`, which the R22 sequence + /// keeps its own separate clause for and which must never be reachable from here. + public static func admitsSend(opcode: UInt8, + payload: [UInt8], + ecgGateOptIn: Bool, + isMG: Bool, + broadcastHrOptIn: Bool) -> Bool { + guard opcode == setDeviceConfigValueCmd else { return false } + guard let key = keyName(inSendPayload: payload) else { return false } + return isWritableKey(key, ecgGateOptIn: ecgGateOptIn, isMG: isMG, broadcastHrOptIn: broadcastHrOptIn) + } + + /// The read verb the post-write verification is allowed to send, and only that one. 128 + /// (`GET_FF_VALUE`) is the other namespace's read verb and is not needed here. + public static func isReadBackOpcode(_ opcode: UInt8) -> Bool { opcode == getDeviceConfigValueCmd } + + // MARK: - Frames + + /// The `SET_DEVICE_CONFIG_VALUE(119)` payload that sets the ECG gate. + /// + /// Deliberately the SAME 33-byte single-value body the Broadcast-HR write has used on real hardware + /// since #181 — `[0x01] + [name NUL-padded to 32][value]`. The strap serves multi-character values + /// (`max_collection_backlog` reads `"0.0"`), so the READ side must handle them; but this key's observed + /// value is a single ASCII digit and a one-character write is the shape hardware has already accepted. + /// Writing a longer value is not needed here and is not inferred into existence. + public static func writePayload(on: Bool) -> [UInt8] { + [0x01] + Whoop5Config.deviceConfigBody(name: ecgRawDataKey, value: value(on: on)) + } + + /// The `GET_DEVICE_CONFIG_VALUE(121)` payload that reads the ECG gate back. Same request body the + /// read probe uses, so both paths ask in exactly one way. + public static func readBackPayload() -> [UInt8] { + DeviceConfigReadProbe.requestBody(key: ecgRawDataKey) + } +} + +// MARK: - Report + +/// The result of one ECG-gate write + mandatory read-back, as a copyable report. +/// +/// Order-dependent and pure (`noteWriteAck` → `noteReadBack`/`noteReadBackTimeout` → `render`), so +/// `swift test` covers the whole verdict table without a strap. Kotlin twin: +/// `EcgRawDataGateReport` in `android/…/protocol/DeviceConfigWriteGate.kt`; the rendered text is +/// byte-identical across platforms so a shared strap log reads the same either side. +public struct EcgRawDataGateReport: Equatable, Sendable { + + /// What the run established. The headline, and deliberately blunt about the case that matters: + /// a write whose ack said SUCCESS but whose read-back did not move is `.unchanged`, not success. + public enum Verdict: String, Equatable, Sendable { + /// Read-back returned exactly the value that was requested. The only success case. + case confirmed + /// Read-back returned a DIFFERENT value than requested — the write did not take. + case unchanged + /// The strap answered the read-back but did not echo the key, so no value can be claimed. + case notClaimed + /// The strap refused the read-back verb, or answered FAILURE for the key. + case refused + /// No reply to the read-back inside its window. + case silent + /// A reply arrived that could not be decoded (CRC, envelope, short record). + case undecodable + /// The read-back has not resolved yet. + case pending + } + + /// The value that was requested, as the strap stores it ("1" or "0"). + public let requested: String + /// Result code of the WRITE's own COMMAND_RESPONSE, when one arrived. Recorded, never trusted. + public private(set) var writeResultCode: Int? + /// The value the read-back actually returned. nil means "no value claimed", never "zero". + public private(set) var storedValue: String? + /// Result code of the READ-BACK's COMMAND_RESPONSE. + public private(set) var readBackResultCode: Int? + /// Raw read-back record bytes as hex, always reported whatever else decodes. + public private(set) var readBackRecordHex: String? + /// Trace lines, one per round-trip. + public private(set) var trace: [String] = [] + /// The verdict so far. + public private(set) var verdict: Verdict = .pending + + public init(on: Bool) { + self.requested = DeviceConfigWriteGate.valueString(on: on) + trace.append("SET_DEVICE_CONFIG_VALUE(119) key=\"\(DeviceConfigWriteGate.ecgRawDataKey)\" value='\(requested)' sent") + } + + /// Record the write's own ack. It is logged and NOT used to decide anything: #891 established that a + /// SUCCESS result code does not prove state changed. + public mutating func noteWriteAck(resultCode: Int?) { + writeResultCode = resultCode + let label = resultCode.map { "\(FeatureFlagProbe.resultLabel($0))(\($0))" } ?? "(unlabelled)" + trace.append("write ack → result=\(label) — recorded, not treated as proof; the read-back below is the proof") + } + + /// Record the decoded read-back and reach a verdict. + public mutating func noteReadBack(_ r: DeviceConfigReadProbe.ValueResponse) { + readBackResultCode = r.resultCode + readBackRecordHex = r.recordHex + let stored = r.stringValue(for: DeviceConfigWriteGate.ecgRawDataKey) + storedValue = stored + var line = "GET_DEVICE_CONFIG_VALUE(121) key=\"\(DeviceConfigWriteGate.ecgRawDataKey)\"" + if let c = r.resultCode { line += " → result=\(FeatureFlagProbe.resultLabel(c))(\(c))" } else { line += " →" } + if let stored { line += " value='\(stored)'" } + line += " record=[\(r.recordHex)]" + trace.append(line) + + if r.isUnsupported || r.isFailure { + verdict = .refused + } else if let stored { + verdict = stored == requested ? .confirmed : .unchanged + } else { + verdict = .notClaimed + } + } + + /// Record a read-back reply that could not be decoded. + public mutating func noteReadBackFailure(_ f: DeviceConfigReadProbe.ParseFailure) { + let why: String + switch f { + case .crc: why = "CRC failed — frame rejected (never decoded)" + case .envelope: why = "not a COMMAND_RESPONSE envelope" + case .wrongCommand: why = "COMMAND_RESPONSE for a different command" + case .truncated: why = "record too short to hold a response" + } + trace.append("read-back reply not decoded: \(why)") + verdict = .undecodable + } + + /// Record the strap answering nothing at all to the read-back. + public mutating func noteReadBackTimeout(seconds: Int) { + trace.append("GET_DEVICE_CONFIG_VALUE(121) → no COMMAND_RESPONSE within \(seconds)s") + verdict = .silent + } + + /// One-line summary, suitable for a Settings row. + public var summary: String { + switch verdict { + case .confirmed: + return "Strap now reports \(DeviceConfigWriteGate.ecgRawDataKey)='\(requested)' (read back, not just acked)." + case .unchanged: + return "Write did NOT take: asked for '\(requested)', strap still reports '\(storedValue ?? "?")'." + case .notClaimed: + return "Strap answered the read-back but did not echo the key, so no value is claimed." + case .refused: + return "Strap refused the read-back for this key — the stored value is unknown." + case .silent: + return "No reply to the read-back — the stored value is unknown." + case .undecodable: + return "The read-back reply did not decode — the stored value is unknown." + case .pending: + return "Waiting for the read-back…" + } + } + + /// The full copyable report. + public func render() -> String { + var sb = "#891 ECG RAW-DATA GATE — WHOOP MG\n" + sb += "Key: \(DeviceConfigWriteGate.ecgRawDataKey) (the strap's own 115/116 enumeration listed it)\n" + sb += "Wrote '\(requested)' via SET_DEVICE_CONFIG_VALUE(119), then read it back with " + sb += "GET_DEVICE_CONFIG_VALUE(121). SET_FF_VALUE(120) is never sent from this path, and no other " + sb += "device-config key is writable from it.\n" + sb += "\nVerdict: \(verdict.rawValue) — \(summary)\n" + sb += "\nExchange:\n" + for line in trace { sb += " " + line + "\n" } + sb += "\nWhether this gate actually produces ECG data is UNKNOWN. If it now reads '1' and a " + sb += "TOGGLE_LABRADOR listen still yields zero packets, that is a real result for #891 — please " + sb += "share this report there either way.\n" + return sb + } +} diff --git a/Packages/WhoopProtocol/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..0d2032bc76 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,489 @@ 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()) - 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; " - + "GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed") + 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")) + } + + /// A successful enumeration is evidence about what the firmware HOLDS, not proof of what it would + /// ACCEPT — and it says nothing at all about the feature-flag namespace, where most of the catalogue + /// is aimed. So a run can be asked to sweep anyway, and then the candidate steps must actually happen. + func testAForcedSweepAsksTheCandidatesEvenAfterEnumerationSucceeds() { + var report = DeviceConfigReadProbeReport( + family: .whoop5, + knownFlagKeys: ["enable_r22_packets", "hr_ch_switching"], + batch: ConfigKeySweep.batch(from: 0, limit: 2), + forceCandidateSweep: true) + XCTAssertTrue(report.runsCandidateSweep) + + guard report.nextStep() != nil else { return XCTFail("s1") } + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 1))) + guard report.nextStep() != nil else { return XCTFail("s2") } + // The Broadcast-HR key: enumerated, and one NOOP already had — so `newKeysFound` stays empty and + // the headline is free to report what the sweep established rather than what enumeration found. + XCTAssertTrue(report.noteEnumerationNext( + nextReply(enumNext(index: 1, key: "whoop_live_hr_in_adv_ind_pkt")))) + guard report.nextStep() != nil else { return XCTFail("s3") } + XCTAssertFalse(report.noteEnumerationNext(nextReply(enumNext(index: 0xFF, key: nil, validKey: false)))) + XCTAssertEqual(report.enumerationVerb, .answered) + XCTAssertFalse(report.enumeratedKeys.isEmpty) + + var candidates: [String] = [] + var guard_ = 0 + while let step = report.nextStep(), guard_ < 200 { + guard_ += 1 + if step.group == .candidate { candidates.append(step.key) } + // Every candidate answers FAILURE(0) — "the firmware has no key by this name". + let code = step.group == .candidate ? 0 : 1 + report.noteReply(.init(resultCode: code, record: echoRecord(step.key, value: 0x30)), for: step) + } + // Both verbs answered in this fixture, so each name appears once per verb. + var distinct: [String] = [] + for k in candidates where !distinct.contains(k) { distinct.append(k) } + XCTAssertEqual(distinct, ConfigKeySweep.batch(from: 0, limit: 2).candidates.map(\.key)) + // The report must SAY the sweep was forced, and must not claim enumeration settled the question. let text = report.render() - XCTAssertFalse(text.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)")) + XCTAssertTrue(text.contains("FULL SWEEP: asked even though enumeration succeeded")) + XCTAssertFalse(text.contains("skipped — the strap enumerated its own device-config keys")) + // A fully-negative forced sweep against a fully-enumerated strap is the strong clean negative. + XCTAssertTrue(report.verdict.contains("clean negative"), report.verdict) + XCTAssertTrue(report.verdict.contains("enumerated in full"), report.verdict) } - /// 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. - func testOneRefusalIsNotGeneralisedToTheVerbThatWasNeverHeardFrom() { - var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, - candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) - guard let s128 = report.nextStep() else { return XCTFail("128") } - XCTAssertEqual(s128.opcode, 128) - report.noteReply(.init(resultCode: 3, record: [0x00]), for: s128) - guard let s121 = report.nextStep() else { return XCTFail("121") } - XCTAssertEqual(s121.opcode, 121) - report.noteTimeout(for: s121, seconds: 8) + /// The default is unchanged: forcing is opt-in, so an ordinary run still skips the sweep and still + /// tells the reader that a forced run is available. + func testTheSweepStaysAFallbackByDefaultAndSaysAForcedRunExists() { + var report = smallReport() + XCTAssertTrue(report.runsCandidateSweep, "with nothing enumerated yet the sweep is still on the table") + guard report.nextStep() != nil else { return XCTFail("s1") } + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 1))) + guard report.nextStep() != nil else { return XCTFail("s2") } + XCTAssertTrue(report.noteEnumerationNext(nextReply(enumNext(index: 1, key: "enable_rfid")))) + guard report.nextStep() != nil else { return XCTFail("s3") } + XCTAssertFalse(report.noteEnumerationNext(nextReply(enumNext(index: 0xFF, key: nil, validKey: false)))) + XCTAssertFalse(report.runsCandidateSweep, "an enumerated namespace turns the fallback off") + + var guard_ = 0 + while let step = report.nextStep(), guard_ < 200 { + guard_ += 1 + XCTAssertNotEqual(step.group, .candidate) + report.noteReply(.init(resultCode: 1, record: echoRecord(step.key, value: 0x32)), for: step) + } + let text = report.render() + XCTAssertTrue(text.contains("skipped — the strap enumerated its own device-config keys")) + XCTAssertTrue(text.contains("re-run with the full name sweep")) + XCTAssertFalse(text.contains("FULL SWEEP")) + } - 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); " - + "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") - } - - /// 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: []) - guard let s128 = report.nextStep() else { return XCTFail("128") } - report.noteFailure(.crc, for: s128) - guard let s121 = report.nextStep() else { return XCTFail("121") } - report.noteFailure(.envelope, for: s121) - - XCTAssertEqual(report.featureFlagVerb, .undecodable) - XCTAssertEqual(report.deviceConfigVerb, .undecodable) + /// The `sig` line is the family the forced sweep exists to reach: it lives in the FEATURE-FLAG + /// namespace, which the device-config enumeration never covers. + func testTheCatalogueStillLeadsWithTheSigSeriesInTheFeatureFlagNamespace() { + let sig = ConfigKeySweep.catalogue.filter { $0.derivation == .sigSeries } + XCTAssertFalse(sig.isEmpty) + XCTAssertTrue(sig.allSatisfy { $0.namespace == .featureFlag }) + XCTAssertEqual(ConfigKeySweep.catalogue.first?.derivation, .sigSeries) + } + + /// The #874 discipline, inherited: the strap's own end marker stops the walk, but a name OUR parser + /// declines is counted and stepped over — one bad entry must not throw away every key after it. + func testAnUndecodableNameIsSteppedOverRatherThanEndingTheWalk() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 3))) + _ = report.nextStep() + // validKey = 1 but the name bytes are not printable ASCII, so `key` is nil: skippable, not the end. + XCTAssertTrue(report.noteEnumerationNext( + FeatureFlagProbe.NextResponse(resultCode: 1, revision: 10, index: 1, validKey: true, key: nil))) + _ = report.nextStep() + XCTAssertTrue(report.noteEnumerationNext(nextReply(enumNext(index: 2, key: "whoop_after_the_bad_one")))) + XCTAssertEqual(report.enumeratedKeys, ["whoop_after_the_bad_one"]) + XCTAssertEqual(report.enumerationSkipped, 1) + } + + /// A refused enumeration is the case the guessing fallback exists for — and it must cost exactly one + /// round-trip, not one per key. + func testAnUnsupportedEnumerationCostsOneRoundTripAndOpensTheFallback() { + var report = smallReport() + guard let s1 = report.nextStep() else { return XCTFail("s1") } + XCTAssertEqual(s1.opcode, 115) + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) + XCTAssertEqual(report.enumerationVerb, .unsupported) + + guard let s2 = report.nextStep() else { return XCTFail("s2") } + XCTAssertEqual(s2.opcode, DeviceConfigReadProbe.getFeatureFlagValueCmd, + "116 must not be asked once 115 refused") + XCTAssertEqual(s2.group, .discovery) + } + + /// A silent enumeration retires the pair after ONE timeout rather than one per entry. + func testASilentEnumerationRetiresAfterOneTimeout() { + var report = smallReport() + guard let s1 = report.nextStep() else { return XCTFail("s1") } + report.noteTimeout(for: s1, seconds: 8) + XCTAssertEqual(report.enumerationVerb, .silent) + guard let s2 = report.nextStep() else { return XCTFail("s2") } + XCTAssertEqual(s2.group, .discovery) + XCTAssertTrue(report.render().contains("(none — no reply to 115)")) + } + + func testAnUndecodableEnumerationReplyRetiresIt() { + var report = smallReport() + guard let s1 = report.nextStep() else { return XCTFail("s1") } + report.noteFailure(.crc, for: s1) + XCTAssertEqual(report.enumerationVerb, .undecodable) + guard let s2 = report.nextStep() else { return XCTFail("s2") } + XCTAssertEqual(s2.group, .discovery) + XCTAssertEqual(report.stopReason, "CRC failed — frame rejected (never decoded)") + } + + // MARK: - Cross-namespace + + /// Two round-trips that settle whether the namespaces are separate — the result shapes every later + /// sweep, so it is asked of each verb that answered. + func testCrossNamespaceIsAskedOfEachAnsweringVerb() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) + + guard let d1 = report.nextStep() else { return XCTFail("d1") } + report.noteReply(.init(resultCode: 1, record: echoRecord(d1.key, value: 0x32)), for: d1) + guard let d2 = report.nextStep() else { return XCTFail("d2") } + report.noteReply(.init(resultCode: 1, record: echoRecord(d2.key, value: 0x30)), for: d2) + + guard let x1 = report.nextStep() else { return XCTFail("x1") } + XCTAssertEqual(x1.group, .crossNamespace) + XCTAssertEqual(x1.opcode, DeviceConfigReadProbe.getFeatureFlagValueCmd) + XCTAssertEqual(x1.key, DeviceConfigReadProbe.deviceConfigDiscoveryKey) + report.noteReply(.init(resultCode: 0, record: []), for: x1) + + guard let x2 = report.nextStep() else { return XCTFail("x2") } + XCTAssertEqual(x2.group, .crossNamespace) + XCTAssertEqual(x2.opcode, DeviceConfigReadProbe.getDeviceConfigValueCmd) + XCTAssertEqual(x2.key, "enable_r22_packets") + report.noteReply(.init(resultCode: 0, record: []), for: x2) + + XCTAssertEqual(report.featureFlagVerbOnDeviceConfigKey, .unknown) + XCTAssertEqual(report.deviceConfigVerbOnFlagKey, .unknown) + XCTAssertTrue(report.render().contains("the namespaces are separate")) + } + + /// If one verb turns out to serve both namespaces, everything afterwards goes through it — halving + /// the work every future sweep needs, on evidence gathered in the same run. + func testAVerbShownToServeBothNamespacesCarriesEverythingAfterwards() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) + guard let d1 = report.nextStep() else { return XCTFail("d1") } + report.noteReply(.init(resultCode: 1, record: echoRecord(d1.key, value: 0x32)), for: d1) + guard let d2 = report.nextStep() else { return XCTFail("d2") } + report.noteReply(.init(resultCode: 1, record: echoRecord(d2.key, value: 0x30)), for: d2) + guard let x1 = report.nextStep() else { return XCTFail("x1") } + report.noteReply(.init(resultCode: 0, record: []), for: x1) + guard let x2 = report.nextStep() else { return XCTFail("x2") } + // 121 DOES see a feature-flag key. + report.noteReply(.init(resultCode: 1, record: echoRecord(x2.key, value: 0x32)), for: x2) + XCTAssertEqual(report.deviceConfigVerbOnFlagKey, .exists) + + guard let k1 = report.nextStep() else { return XCTFail("k1") } + XCTAssertEqual(k1.opcode, DeviceConfigReadProbe.getDeviceConfigValueCmd, + "the verb proved to serve both namespaces carries the rest of the plan") + XCTAssertTrue(report.render().contains("GET_DEVICE_CONFIG_VALUE(121) serves BOTH namespaces.")) + } + + // MARK: - The sweep + + /// A fully-negative sweep is a RESULT, and the verdict must say so rather than reading like a failure. + func testAFullyNegativeSweepIsACleanNegativeVerdict() { + var (report, first) = driveToCandidates(limit: 2) + guard var step: DeviceConfigReadProbeReport.Step = first else { return XCTFail("no candidate") } + var asked: [String] = [] + while true { + XCTAssertEqual(step.group, .candidate) + asked.append(step.key) + report.noteReply(.init(resultCode: 0, record: []), for: step) + guard let next = report.nextStep() else { break } + step = next + } + // Each NAME is asked through every verb that answered — here both, so each appears twice. That is + // the point: a name asked only through the verb its namespace guess named would answer FAILURE + // from the wrong namespace and be indistinguishable from "no such key". + XCTAssertEqual(asked, ["enable_sig1", "enable_sig1", "enable_sig2", "enable_sig2"]) + XCTAssertEqual(Set(asked.map { $0 }).count, 2) + // The verdict counts NAMES, not round-trips. XCTAssertEqual(report.verdict, - "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") - 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 - /// finding about the firmware. - func testAProbeThatAskedNothingClaimsNothing() { - let report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, candidateKeys: []) - XCTAssertEqual(report.featureFlagVerb, .untried) - XCTAssertEqual(report.deviceConfigVerb, .untried) + "asked 2 candidate key name(s); this firmware has none of them (a clean negative)") + XCTAssertTrue(report.newKeysFound.isEmpty) + } + + /// The routing fix itself: every candidate is asked through BOTH value verbs when both answered, and + /// through only the survivor when one is dead. + func testEveryCandidateIsAskedThroughEveryAnsweringVerb() { + var (report, first) = driveToCandidates(limit: 2) + guard var step: DeviceConfigReadProbeReport.Step = first else { return XCTFail("no candidate") } + var byKey: [String: [UInt8]] = [:] + while true { + byKey[step.key, default: []].append(step.opcode) + report.noteReply(.init(resultCode: 0, record: []), for: step) + guard let next = report.nextStep() else { break } + step = next + } + for (key, opcodes) in byKey { + XCTAssertEqual(Set(opcodes), [121, 128], "\(key) must be asked through both verbs") + } + // And the per-name rendering shows which verb said what, rather than collapsing them. + let text = report.render() + XCTAssertTrue(text.contains("121=unknown · 128=unknown"), text) + XCTAssertTrue(text.contains("each name asked through 2 verb(s)"), text) + } + + /// And a hit is the headline, named in the verdict so a strap log's first line carries the finding. + func testACandidateThatExistsBecomesTheHeadline() { + var (report, first) = driveToCandidates(limit: 2) + 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, - "no read verb answered — GET_FF_VALUE(128) not asked; " - + "GET_DEVICE_CONFIG_VALUE(121) not asked") + "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() + } + 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) } - func testAnAnsweringFeatureFlagVerbWalksTheFifteenRemainingKnownFlags() { + /// The default catalogue is smaller than one run's budget, so a real run reports nothing untested. + func testAFullRunOfTodaysCatalogueLeavesNothingUntested() { var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, - candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) - // 128 answers; 121 is refused. - guard let s128 = report.nextStep() else { return XCTFail("128") } - report.noteReply(.init(resultCode: 1, record: echoRecord(s128.key, value: 0x32, lead: [0x01])), for: s128) - guard let s121 = report.nextStep() else { return XCTFail("121") } - report.noteReply(.init(resultCode: 3, record: [0x00]), for: s121) - - var flagSteps: [String] = [] - var candidateSteps: [String] = [] - while let step = report.nextStep() { - XCTAssertEqual(step.opcode, 128, "the refused verb must never be sent again") - switch step.group { - case .knownFlag: flagSteps.append(step.key) - case .candidate: candidateSteps.append(step.key) - case .discovery: XCTFail("discovery is over") - } - report.noteReply(.init(resultCode: 1, record: echoRecord(step.key, value: 0x31, lead: [0x01])), for: step) + batch: ConfigKeySweep.batch(from: 0)) + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) + var candidates = 0 + var steps = 0 + while let step = report.nextStep(), steps < DeviceConfigReadProbe.maxSteps { + steps += 1 + if step.group == .candidate { candidates += 1 } + report.noteReply(.init(resultCode: step.group == .candidate ? 0 : 1, + record: echoRecord(step.key, value: 0x32)), for: step) } - XCTAssertEqual(flagSteps, Array(flagKeys.dropFirst()), - "the discovery key is not read twice; every other flag is") - XCTAssertEqual(candidateSteps, DeviceConfigReadProbe.oxygenCandidateKeys, - "candidates fall back to the surviving verb") - XCTAssertEqual(report.steps, 2 + 15 + DeviceConfigReadProbe.oxygenCandidateKeys.count) - } - - func testCandidatesPreferTheDeviceConfigVerbWhenBothAnswer() { - var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: ["only_flag"], - candidateKeys: ["enable_spo2"]) - guard let a = report.nextStep() else { return XCTFail("128") } - report.noteReply(.init(resultCode: 1, record: echoRecord(a.key, value: 0x32)), for: a) - guard let b = report.nextStep() else { return XCTFail("121") } - report.noteReply(.init(resultCode: 1, record: echoRecord(b.key, value: 0x31)), for: b) - - // The only known flag was already read during discovery, so the next step is the candidate. - guard let c = report.nextStep() else { return XCTFail("candidate") } - XCTAssertEqual(c.group, .candidate) - XCTAssertEqual(c.opcode, 121, "the device-config namespace is the one this probe exists to reach") - XCTAssertEqual(c.key, "enable_spo2") - } - - func testThePlanIsCappedEvenWithAnAbsurdKeyList() { - let many = (0..<500).map { "key_\($0)" } - var report = DeviceConfigReadProbeReport(family: .whoop4, knownFlagKeys: many, candidateKeys: many) + // One round-trip per (name × answering verb) — both verbs answered here, so the whole catalogue + // costs twice its length. This is also the case that proves the safety cap was raised enough: + // the old cap of 128 would have truncated this run and reported it as "cap reached". + XCTAssertEqual(candidates, ConfigKeySweep.catalogue.count * 2) + XCTAssertNil(report.stopReason, "a full default run must not hit the safety cap") + XCTAssertTrue(report.render().contains("none untested")) + } + + /// The safety cap still binds, whatever the plan holds. + func testThePlanIsCappedEvenWhenTheStrapEnumeratesForever() { + var report = smallReport() + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 1, revision: 10, count: 9999))) var seen = 0 - while let step = report.nextStep() { + while let step = report.nextStep(), seen < 500 { seen += 1 - XCTAssertLessThanOrEqual(seen, DeviceConfigReadProbe.maxSteps + 1, "the plan must terminate") - report.noteReply(.init(resultCode: nil, record: echoRecord(step.key, value: 0x31)), for: step) + if step.group == .enumerate { + // A firmware whose cursor never advances: always a valid entry, never the end marker. + _ = report.noteEnumerationNext(nextReply(enumNext(index: 1, key: "whoop_stuck"))) + } else { + report.noteReply(.init(resultCode: 0, record: []), for: step) + } } - XCTAssertEqual(seen, DeviceConfigReadProbe.maxSteps) - XCTAssertEqual(report.stopReason, "safety cap of 64 round-trips reached") - } - - func testAnUndecodableReplyRetiresTheVerb() { - var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, candidateKeys: []) - guard let step = report.nextStep() else { return XCTFail("first") } - report.noteFailure(.crc, for: step) - XCTAssertEqual(report.featureFlagVerb, .undecodable) - guard let next = report.nextStep() else { return XCTFail("second") } - XCTAssertEqual(next.opcode, 121, "the undecodable verb is not retried") - report.noteTimeout(for: next, seconds: 8) - XCTAssertNil(report.nextStep()) - XCTAssertEqual(report.stopReason, "CRC failed — frame rejected (never decoded)") + XCTAssertLessThanOrEqual(report.steps, DeviceConfigReadProbe.maxSteps) + XCTAssertNotNil(report.stopReason) } - func testAVerdictAlreadyReachedIsNotRewrittenByALaterReply() { - var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: ["a", "b"], candidateKeys: []) - guard let first = report.nextStep() else { return XCTFail("first") } - report.noteReply(.init(resultCode: 1, record: echoRecord("a", value: 0x31)), for: first) - XCTAssertEqual(report.featureFlagVerb, .answered) - // A later UNSUPPORTED on the same verb sticks; a later SUCCESS after that does not undo it. - guard let second = report.nextStep() else { return XCTFail("second") } - report.noteReply(.init(resultCode: 3, record: [0x00]), for: second) - XCTAssertEqual(report.deviceConfigVerb, .unsupported) - report.noteReply(.init(resultCode: 1, record: [0x00]), for: second) - XCTAssertEqual(report.deviceConfigVerb, .unsupported, "a refusal is not upgraded away") + /// Drive the plan with enumeration refused, stopping at the FIRST candidate step and handing it back + /// alongside the report (a pulled step cannot be pushed back, so the helper must not swallow it). + private func driveToCandidates(limit: Int) + -> (report: DeviceConfigReadProbeReport, first: DeviceConfigReadProbeReport.Step?) { + var report = smallReport(limit: limit) + _ = report.nextStep() + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) + while let step = report.nextStep() { + if step.group == .candidate { return (report, step) } + report.noteReply(.init(resultCode: 1, record: echoRecord(step.key, value: 0x32)), for: step) + } + return (report, nil) } // MARK: - Report - func testCandidateKeysAreLabelledAsGuesses() { - var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: ["f"], - candidateKeys: ["enable_spo2"]) - guard let a = report.nextStep() else { return XCTFail("a") } - report.noteReply(.init(resultCode: 1, record: echoRecord("f", value: 0x32)), for: a) - guard let b = report.nextStep() else { return XCTFail("b") } - report.noteReply(.init(resultCode: 1, record: echoRecord(b.key, value: 0x31)), for: b) - guard let c = report.nextStep() else { return XCTFail("c") } - report.noteReply(.init(resultCode: 0, record: [0x00]), for: c) - let text = report.render() - XCTAssertTrue(text.contains("Candidate oxygen keys — GUESSES, never observed on a wire or in any table")) - XCTAssertTrue(text.contains("enable_spo2")) - XCTAssertTrue(text.contains("no value (result=FAILURE(0))")) - } - - func testOxygenCandidateListIsShortAndOxygenNamed() { - let keys = DeviceConfigReadProbe.oxygenCandidateKeys - XCTAssertFalse(keys.isEmpty) - XCTAssertLessThanOrEqual(keys.count, 12, "keep the guess list short — it is a guess list") - XCTAssertEqual(Set(keys).count, keys.count, "no duplicates") - for k in keys { - XCTAssertTrue(k.contains("spo2") || k.contains("oxygen") || k.contains("pulse_ox"), - "\(k) does not read as oxygen-related") - XCTAssertLessThanOrEqual(k.utf8.count, DeviceConfigReadProbe.nameFieldBytes, - "\(k) would be truncated by the 32-byte name field") - } - } - - /// GOLDEN: the exact rendered report, byte-for-byte. Its Kotlin twin - /// (`DeviceConfigReadProbeTest.goldenReportIsByteIdenticalAcrossPlatforms`) asserts the SAME literal, - /// so a strap log reads identically on either platform — a whitespace or wording drift on one side - /// fails there rather than in a user's log. - /// - /// The first record's trailing `00`, and the seven-byte UNSUPPORTED record, are the puffin envelope's - /// 4-byte inner padding showing through — which is exactly why the value is read as "the byte after - /// the echoed name field" and not "the last byte of the record". + /// Byte-for-byte golden, asserted identically by the Kotlin twin, so a shared strap log reads the same + /// either side and a wording drift fails here rather than in a user's log. func testGoldenReportIsByteIdenticalAcrossPlatforms() { - var report = DeviceConfigReadProbeReport(family: .whoop5, - knownFlagKeys: ["enable_r22_packets", "hr_ch_switching"], - candidateKeys: ["enable_spo2"]) - // 128 answers with a value; 121 is refused; the remaining flag reads through 128; the guessed - // candidate falls back to 128 and comes back FAILURE. + var report = smallReport() guard let s1 = report.nextStep() else { return XCTFail("s1") } - let f1 = whoop5Response(cmd: 128, payload: payload(result: 1, - record: echoRecord("enable_r22_packets", value: 0x32, lead: [0x01]))) - guard case .success(let r1) = DeviceConfigReadProbe.parse(frame: f1, family: .whoop5, expecting: 128) else { - return XCTFail("parse s1") - } - report.noteReply(r1, for: s1) + XCTAssertEqual(s1.opcode, 115) + report.noteEnumerationStart(startReply(enumStart(result: 3, revision: 0, count: 0))) guard let s2 = report.nextStep() else { return XCTFail("s2") } - let f2 = whoop5Response(cmd: 121, payload: payload(result: 3, record: [0x00, 0x00, 0x00, 0x00])) - guard case .success(let r2) = DeviceConfigReadProbe.parse(frame: f2, family: .whoop5, expecting: 121) else { - return XCTFail("parse s2") - } - report.noteReply(r2, for: s2) - + report.noteReply(.init(resultCode: 1, record: echoRecord("enable_r22_packets", value: 0x32)), for: s2) guard let s3 = report.nextStep() else { return XCTFail("s3") } - report.noteReply(.init(resultCode: 1, record: echoRecord("hr_ch_switching", value: 0x32, lead: [0x01])), for: s3) - + report.noteReply(.init(resultCode: 1, + record: echoRecord(DeviceConfigReadProbe.deviceConfigDiscoveryKey, + value: 0x30)), for: s3) guard let s4 = report.nextStep() else { return XCTFail("s4") } report.noteReply(.init(resultCode: 0, record: [0x01, 0x00]), for: s4) + guard let s5 = report.nextStep() else { return XCTFail("s5") } + report.noteReply(.init(resultCode: 0, record: [0x01, 0x00]), for: s5) + guard let s6 = report.nextStep() else { return XCTFail("s6") } + report.noteReply(.init(resultCode: 1, record: echoRecord("hr_ch_switching", value: 0x32)), for: s6) + // Two names × two answering verbs = four candidate round-trips. + for label in ["c1", "c2", "c3", "c4"] { + guard let c = report.nextStep() else { return XCTFail(label) } + XCTAssertEqual(c.group, .candidate) + report.noteReply(.init(resultCode: 0, record: [0x01, 0x00]), for: c) + } XCTAssertNil(report.nextStep()) - 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 (each name asked through 2 verb(s)) + + sig series (T8) — the firmware numbers its signal chains; sig11/sig12 are the two we have (2): + 1. enable_sig1 121=unknown · 128=unknown + 2. enable_sig2 121=unknown · 128=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/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigWriteGateTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigWriteGateTests.swift new file mode 100644 index 0000000000..2f11dbf649 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigWriteGateTests.swift @@ -0,0 +1,339 @@ +import XCTest +@testable import WhoopProtocol + +/// #891: the ECG raw-data gate — the write allowlist, the body it builds, and the mandatory read-back. +/// +/// The allowlist tests are the point of this file. `DeviceConfigWriteGate.admitsSend` is the SAME +/// predicate the 5/MG send path consults, so proving here that it refuses an opcode or a key is proving +/// it about the real wire path rather than about a copy of the rule. +final class DeviceConfigWriteGateTests: XCTestCase { + + // MARK: - Helpers + + /// The payload the send path would hold for a device-config write of `key`. + private func payload(key: String, value: UInt8 = 0x31) -> [UInt8] { + [0x01] + Whoop5Config.deviceConfigBody(name: key, value: value) + } + + /// A real 5/MG COMMAND_RESPONSE frame carrying `record` for command 121, CRC16 header + CRC32 body, + /// so `DeviceConfigReadProbe.parse` runs its CRC gate on these fixtures rather than being bypassed. + /// Same construction as the #103 read-probe tests. + private func readBackFrame(record: [UInt8], cmd: UInt8 = 121, result: UInt8 = 1) -> [UInt8] { + var inner: [UInt8] = [36, 1, cmd, 0x0A, result] + record // type, seq, cmd, 2-byte hdr, record + let pad = (4 - inner.count % 4) % 4 + if pad > 0 { inner += [UInt8](repeating: 0, count: pad) } + let declLen = inner.count + 4 + var frame: [UInt8] = [0xAA, 0x01, UInt8(declLen & 0xFF), UInt8((declLen >> 8) & 0xFF), 0x00, 0x01] + let c16 = crc16Modbus(Array(frame[0..<6])) + frame += [UInt8(c16 & 0xFF), UInt8((c16 >> 8) & 0xFF)] + frame += inner + let c32 = crc32(inner) + frame += [UInt8(c32 & 0xFF), UInt8((c32 >> 8) & 0xFF), UInt8((c32 >> 16) & 0xFF), UInt8((c32 >> 24) & 0xFF)] + return frame + } + + /// A 121 reply record echoing `key` in a 32-byte NUL-padded field, then `value` as ASCII. + private func echoRecord(key: String, value: String) -> [UInt8] { + var rec = [UInt8](repeating: 0, count: DeviceConfigWriteGate.nameFieldBytes) + for (i, b) in Array(key.utf8).enumerated() where i < rec.count { rec[i] = b } + return rec + Array(value.utf8) + } + + // MARK: - The allowlist: what it admits + + func testAdmitsEcgKeyOnlyWhenOptedInOnAnAttestedMG() { + let p = payload(key: DeviceConfigWriteGate.ecgRawDataKey) + // The one combination that is allowed. + XCTAssertTrue(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: true, isMG: true, broadcastHrOptIn: false)) + // Opt-in off — a default install can never form these bytes. + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: false, isMG: true, broadcastHrOptIn: false)) + // Opted in but the strap is not an attested MG (a plain 5.0, or DIS not read yet). + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: true, isMG: false, broadcastHrOptIn: false)) + // The Broadcast-HR opt-in must NOT carry the ECG key — that cross-authorisation is the exact + // hole the key-aware gate closes. + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: false, isMG: true, broadcastHrOptIn: true)) + } + + func testAdmitsBroadcastHrKeyOnlyUnderItsOwnOptIn() { + let p = payload(key: DeviceConfigWriteGate.broadcastHrKey) + XCTAssertTrue(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: false, isMG: false, broadcastHrOptIn: true)) + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: false, isMG: false, broadcastHrOptIn: false)) + // The ECG opt-in must not carry the Broadcast-HR key either — the rule runs both ways. + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: true, isMG: true, broadcastHrOptIn: false)) + // Broadcast HR is not MG-gated: it works on a plain 5.0, and must keep doing so. + XCTAssertTrue(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: p, ecgGateOptIn: true, isMG: false, broadcastHrOptIn: true)) + } + + // MARK: - The allowlist: what it refuses + + func testRefusesEveryOtherEnumeratedKeyEvenWithBothOptInsOn() { + // The five keys the strap listed that this app has no business writing. Every one refused, with + // both opt-ins on and an attested MG — the most permissive state that exists. + for key in DeviceConfigWriteGate.outOfScopeKeys { + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: payload(key: key), + ecgGateOptIn: true, isMG: true, broadcastHrOptIn: true), + "\(key) must never be writable from this path") + } + XCTAssertEqual(DeviceConfigWriteGate.outOfScopeKeys.count, 5) + // The five out-of-scope keys plus the two writable ones are exactly what the strap enumerated. + XCTAssertEqual(Set(DeviceConfigWriteGate.outOfScopeKeys) + .union([DeviceConfigWriteGate.ecgRawDataKey, DeviceConfigWriteGate.broadcastHrKey]), + Set(DeviceConfigWriteGate.enumeratedKeys)) + XCTAssertEqual(DeviceConfigWriteGate.enumeratedKeys.count, 7) + } + + func testRefusesSetFeatureFlagValue120ForEveryKeyAndEveryOptIn() { + // 120 is the OTHER namespace's write verb (the R22 sequence). It must be unreachable from here + // no matter what the body says or which opt-ins are on. + for key in DeviceConfigWriteGate.enumeratedKeys + Whoop5Config.enableR22Sequence.map(\.name) { + for ecg in [true, false] { + for hr in [true, false] { + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: DeviceConfigWriteGate.setFeatureFlagValueCmd, payload: payload(key: key), + ecgGateOptIn: ecg, isMG: true, broadcastHrOptIn: hr), + "SET_FF_VALUE(120) must never be admitted (key=\(key))") + } + } + } + } + + func testRefusesEveryOtherOpcodeInTheWholeByteRange() { + // Exhaustive: with both opt-ins on, an attested MG, and a body carrying the one writable key, + // 119 is the ONLY opcode this gate admits. All 255 others are refused. + let p = payload(key: DeviceConfigWriteGate.ecgRawDataKey) + var admitted: [UInt8] = [] + for opcode in UInt8.min...UInt8.max { + if DeviceConfigWriteGate.admitsSend(opcode: opcode, payload: p, + ecgGateOptIn: true, isMG: true, broadcastHrOptIn: true) { + admitted.append(opcode) + } + } + XCTAssertEqual(admitted, [DeviceConfigWriteGate.setDeviceConfigValueCmd]) + } + + func testRefusesUnknownAndMalformedKeys() { + let states = [(true, true), (true, false), (false, true), (false, false)] + for (ecg, hr) in states { + // A key nobody has ever seen. + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: payload(key: "enable_something_invented"), + ecgGateOptIn: ecg, isMG: true, broadcastHrOptIn: hr)) + // A near-miss on the real key: prefix, suffix, and case all matter. + for near in ["enable_raw_data_w_ec", "enable_raw_data_w_ecg2", "ENABLE_RAW_DATA_W_ECG"] { + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: payload(key: near), + ecgGateOptIn: ecg, isMG: true, broadcastHrOptIn: hr), "\(near) must not pass") + } + } + // Bodies that are not shaped like a device-config write are refused rather than guessed at. + let good = payload(key: DeviceConfigWriteGate.ecgRawDataKey) + for bad: [UInt8] in [ + [], // empty + [0x01], // b3 byte only + Array(good.dropFirst()), // missing the b3 byte + [0x02] + Array(good.dropFirst()), // wrong b3 byte + Array(good.prefix(20)), // truncated inside the name field + ] { + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: bad, ecgGateOptIn: true, isMG: true, broadcastHrOptIn: true)) + } + } + + func testKeyNameParsingRejectsNonNulPaddingAndNonAscii() { + // A name field with junk AFTER the NUL terminator is not a NUL-padded name field, so nothing is + // claimed — a body cannot smuggle a second string past the terminator. + var body = payload(key: DeviceConfigWriteGate.ecgRawDataKey) + body[1 + DeviceConfigWriteGate.ecgRawDataKey.count + 2] = 0x41 // 'A' amongst the padding + XCTAssertNil(DeviceConfigWriteGate.keyName(inSendPayload: body)) + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: body, ecgGateOptIn: true, isMG: true, broadcastHrOptIn: true)) + // Extending the name itself yields a DIFFERENT name, which the key allowlist then refuses. + var extended = payload(key: DeviceConfigWriteGate.ecgRawDataKey) + extended[1 + DeviceConfigWriteGate.ecgRawDataKey.count] = 0x41 + XCTAssertEqual(DeviceConfigWriteGate.keyName(inSendPayload: extended), "enable_raw_data_w_ecgA") + XCTAssertFalse(DeviceConfigWriteGate.admitsSend( + opcode: 119, payload: extended, ecgGateOptIn: true, isMG: true, broadcastHrOptIn: true)) + // Binary in the name field is refused, not transliterated. + var binary = payload(key: DeviceConfigWriteGate.ecgRawDataKey) + binary[3] = 0xFF + XCTAssertNil(DeviceConfigWriteGate.keyName(inSendPayload: binary)) + // The happy path still round-trips. + XCTAssertEqual(DeviceConfigWriteGate.keyName(inSendPayload: payload(key: "enable_rfid")), + "enable_rfid") + } + + func testReadBackOpcodeIsOnly121() { + var admitted: [UInt8] = [] + for opcode in UInt8.min...UInt8.max where DeviceConfigWriteGate.isReadBackOpcode(opcode) { + admitted.append(opcode) + } + XCTAssertEqual(admitted, [121]) + // Notably NOT the write verbs, and not the other namespace's read verb. + XCTAssertFalse(DeviceConfigWriteGate.isReadBackOpcode(119)) + XCTAssertFalse(DeviceConfigWriteGate.isReadBackOpcode(120)) + XCTAssertFalse(DeviceConfigWriteGate.isReadBackOpcode(128)) + } + + // MARK: - The bytes on the wire + + func testWritePayloadIsTheHardwareValidatedBroadcastHrShape() { + let on = DeviceConfigWriteGate.writePayload(on: true) + // [b3] + [32-byte NUL-padded name] + [value] — 34 bytes, the same shape #181 has written since. + XCTAssertEqual(on.count, 1 + 32 + 1) + XCTAssertEqual(on[0], 0x01) + XCTAssertEqual(DeviceConfigWriteGate.keyName(inSendPayload: on), "enable_raw_data_w_ecg") + XCTAssertEqual(on.last, 0x31) // ASCII '1' + let off = DeviceConfigWriteGate.writePayload(on: false) + XCTAssertEqual(off.last, 0x30) // ASCII '0' + // Both directions differ ONLY in the value byte — reversibility is one byte, not a second path. + XCTAssertEqual(Array(on.dropLast()), Array(off.dropLast())) + } + + func testReadBackPayloadMatchesTheReadProbesRequestShape() { + XCTAssertEqual(DeviceConfigWriteGate.readBackPayload(), + DeviceConfigReadProbe.requestBody(key: "enable_raw_data_w_ecg")) + } + + // MARK: - Multi-character values (max_collection_backlog = "0.0") + + func testStringValueReadsMultiCharacterValues() { + let rec = echoRecord(key: "max_collection_backlog", value: "0.0") + let r = DeviceConfigReadProbe.ValueResponse(resultCode: 1, record: rec) + // The single-byte reader loses the rest; the string reader is what a comparison must use. + XCTAssertEqual(r.value(for: "max_collection_backlog"), 0x30) + XCTAssertEqual(r.stringValue(for: "max_collection_backlog"), "0.0") + } + + func testStringValueStopsAtNulSoEnvelopePaddingIsNotRead() { + // The puffin envelope pads the inner payload to a 4-byte boundary; those NULs are not data. + let rec = echoRecord(key: "enable_raw_data_w_ecg", value: "1") + [0, 0, 0] + let r = DeviceConfigReadProbe.ValueResponse(resultCode: 1, record: rec) + XCTAssertEqual(r.stringValue(for: "enable_raw_data_w_ecg"), "1") + } + + func testStringValueClaimsNothingWhenThereIsNothingToClaim() { + // Key not echoed at all. + let other = DeviceConfigReadProbe.ValueResponse(resultCode: 1, + record: echoRecord(key: "enable_rfid", value: "0")) + XCTAssertNil(other.stringValue(for: "enable_raw_data_w_ecg")) + // Name field present but the record stops there — no value follows. + let bare = DeviceConfigReadProbe.ValueResponse( + resultCode: 1, record: [UInt8](repeating: 0, count: 32)) + XCTAssertNil(bare.stringValue(for: "enable_raw_data_w_ecg")) + // A value slot that starts with NUL is empty, not "". + let empty = DeviceConfigReadProbe.ValueResponse( + resultCode: 1, record: echoRecord(key: "enable_raw_data_w_ecg", value: "") + [0]) + XCTAssertNil(empty.stringValue(for: "enable_raw_data_w_ecg")) + // Non-ASCII is refused rather than transliterated. + let binary = DeviceConfigReadProbe.ValueResponse( + resultCode: 1, record: echoRecord(key: "enable_raw_data_w_ecg", value: "") + [0xFF, 0]) + XCTAssertNil(binary.stringValue(for: "enable_raw_data_w_ecg")) + } + + // MARK: - The verdict table: the ack never decides, the read-back does + + func testConfirmedOnlyWhenTheReadBackReturnsWhatWasAsked() { + var report = EcgRawDataGateReport(on: true) + XCTAssertEqual(report.verdict, .pending) + report.noteWriteAck(resultCode: 1) + // A SUCCESS ack alone must NOT reach a verdict — #891's whole lesson. + XCTAssertEqual(report.verdict, .pending) + let frame = readBackFrame(record: echoRecord(key: "enable_raw_data_w_ecg", value: "1")) + guard case .success(let r) = DeviceConfigReadProbe.parse(frame: frame, family: .whoop5, + expecting: 121) else { + return XCTFail("read-back frame should parse") + } + report.noteReadBack(r) + XCTAssertEqual(report.verdict, .confirmed) + XCTAssertEqual(report.storedValue, "1") + XCTAssertTrue(report.render().contains("enable_raw_data_w_ecg")) + } + + func testSuccessAckWithAnUnmovedValueIsUnchangedNotSuccess() { + // The exact failure mode this design exists for: the strap acks SUCCESS, and the value did not + // move. Reporting that as success is what the read-back is here to prevent. + var report = EcgRawDataGateReport(on: true) + report.noteWriteAck(resultCode: 1) + let frame = readBackFrame(record: echoRecord(key: "enable_raw_data_w_ecg", value: "0")) + guard case .success(let r) = DeviceConfigReadProbe.parse(frame: frame, family: .whoop5, + expecting: 121) else { + return XCTFail("read-back frame should parse") + } + report.noteReadBack(r) + XCTAssertEqual(report.verdict, .unchanged) + XCTAssertEqual(report.storedValue, "0") + XCTAssertTrue(report.summary.contains("did NOT take")) + } + + func testTurningTheGateBackOffConfirmsOnZero() { + var report = EcgRawDataGateReport(on: false) + XCTAssertEqual(report.requested, "0") + let frame = readBackFrame(record: echoRecord(key: "enable_raw_data_w_ecg", value: "0")) + guard case .success(let r) = DeviceConfigReadProbe.parse(frame: frame, family: .whoop5, + expecting: 121) else { + return XCTFail("read-back frame should parse") + } + report.noteReadBack(r) + XCTAssertEqual(report.verdict, .confirmed) + } + + func testRefusedSilentNotClaimedAndUndecodableAreNeverSuccess() { + // FAILURE for the key. + var refused = EcgRawDataGateReport(on: true) + refused.noteReadBack(DeviceConfigReadProbe.ValueResponse(resultCode: 0, record: [])) + XCTAssertEqual(refused.verdict, .refused) + // UNSUPPORTED verb. + var unsupported = EcgRawDataGateReport(on: true) + unsupported.noteReadBack(DeviceConfigReadProbe.ValueResponse(resultCode: 3, record: [])) + XCTAssertEqual(unsupported.verdict, .refused) + // Answered, but the key was not echoed — no value can be claimed. + var notClaimed = EcgRawDataGateReport(on: true) + notClaimed.noteReadBack(DeviceConfigReadProbe.ValueResponse( + resultCode: 1, record: echoRecord(key: "enable_rfid", value: "1"))) + XCTAssertEqual(notClaimed.verdict, .notClaimed) + // No reply at all. + var silent = EcgRawDataGateReport(on: true) + silent.noteReadBackTimeout(seconds: 8) + XCTAssertEqual(silent.verdict, .silent) + // A reply that failed the CRC gate. + var undecodable = EcgRawDataGateReport(on: true) + undecodable.noteReadBackFailure(.crc) + XCTAssertEqual(undecodable.verdict, .undecodable) + for r in [refused, unsupported, notClaimed, silent, undecodable] { + XCTAssertNotEqual(r.verdict, .confirmed) + XCTAssertFalse(r.summary.isEmpty) + } + } + + func testReadBackIsCrcGatedLikeEveryOtherDecode() { + var frame = readBackFrame(record: echoRecord(key: "enable_raw_data_w_ecg", value: "1")) + frame[frame.count - 1] ^= 0xFF // corrupt the CRC32 trailer + guard case .failure(let f) = DeviceConfigReadProbe.parse(frame: frame, family: .whoop5, + expecting: 121) else { + return XCTFail("a corrupt frame must not decode") + } + XCTAssertEqual(f, .crc) + } + + func testRenderNamesTheKeyTheVerbsAndTheOpenQuestion() { + var report = EcgRawDataGateReport(on: true) + report.noteWriteAck(resultCode: 1) + report.noteReadBackTimeout(seconds: 8) + let text = report.render() + XCTAssertTrue(text.contains("SET_DEVICE_CONFIG_VALUE(119)")) + XCTAssertTrue(text.contains("GET_DEVICE_CONFIG_VALUE(121)")) + XCTAssertTrue(text.contains("SET_FF_VALUE(120) is never sent")) + // The honest framing has to survive into the copyable report a user pastes into an issue. + XCTAssertTrue(text.contains("UNKNOWN")) + XCTAssertTrue(text.contains("#891")) + } +} diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index d79b202607..20b0e3368c 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -850,7 +850,9 @@ final class AppModel: ObservableObject { // #103: READ-ONLY device-config READ probe (121/128) — asks the strap for a key's VALUE, the // follow-up to #761's key-NAME enumeration. Writes nothing. User-initiated, Test-Centre-gated in // DevicesView. - func probeDeviceConfigValues() { ble.probeDeviceConfigValues() } + func probeDeviceConfigValues(forceCandidateSweep: Bool = false) { + ble.probeDeviceConfigValues(forceCandidateSweep: forceCandidateSweep) + } func clearDeviceConfigProbe() { ble.clearDeviceConfigProbe() } /// Drop the current strap and clear bond state so a newly-picked strap model connects fresh diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index aaad54f415..cd3e2b0a2f 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 @@ -1536,10 +1539,28 @@ public final class BLEManager: NSObject, ObservableObject { // data the strap emits) and is what the official app sends. Driven only by // enableWhoop5DeepData(). (#174) || (command == .setConfig && PuffinExperiment.deepDataEnabled) - // SET_DEVICE_CONFIG (the Broadcast-HR flag) is allowed ONLY while that opt-in is on — - // it writes one persistent device-config value so the strap advertises standard HR. - // Reversible; driven only by setBroadcastHr(_:). (#181) - || (command == .setDeviceConfig && PuffinExperiment.broadcastHrEnabled) else { + // SET_DEVICE_CONFIG_VALUE (119) writes ONE persistent device-config value. Opcode 119 is + // shared by more than one feature, so an opcode-only clause cannot say "this key and no + // other" — and the clause this replaced admitted ANY device-config key whenever the + // Broadcast-HR opt-in happened to be on. `DeviceConfigWriteGate.admitsSend` parses the key + // NAME out of the body and admits exactly two, each only while its OWN opt-in is on: + // `whoop_live_hr_in_adv_ind_pkt` (#181, driven by setBroadcastHr(_:)) and + // `enable_raw_data_w_ecg` (#891, driven by setEcgRawDataGate(_:), which additionally + // requires the strap to have attested itself an MG). The other five keys the strap's own + // 115/116 enumeration listed are refused unconditionally, and SET_FF_VALUE(120) is refused + // by this predicate outright — the R22 sequence keeps its separate clause above. Same + // discipline as DeviceConfigReadProbe.isReadOnlyOpcode: ONE pure predicate that the send + // path itself consults, so the unit tests that prove what it rejects are proving it about + // this wire path rather than about a copy of the rule. + || DeviceConfigWriteGate.admitsSend(opcode: command.rawValue, + payload: payload, + ecgGateOptIn: PuffinExperiment.ecgRawDataEnabled, + isMG: whoop5Variant.isMG, + broadcastHrOptIn: PuffinExperiment.broadcastHrEnabled) + // GET_DEVICE_CONFIG_VALUE (121) as the ECG gate's mandatory post-write read-back. Allowed + // ONLY while a verification is actually in flight, the same in-flight shape the read + // probes use, and narrowed to 121 alone (isReadBackOpcode) rather than the probe's four. + || (DeviceConfigWriteGate.isReadBackOpcode(command.rawValue) && ecgGateReport != nil) else { log("send(\(command.label)) skipped — no WHOOP 5/MG framing for this command yet") return } @@ -2430,6 +2451,123 @@ public final class BLEManager: NSObject, ObservableObject { log("Broadcast HR: wrote whoop_live_hr_in_adv_ind_pkt=\(on ? "1" : "0")") } + // MARK: #891 ECG raw-data gate (the one WRITE this issue's investigation needs) + + /// Per-step reply window for the read-back. One round-trip, so this bounds the whole verification. + private static let ecgGateReadBackTimeout: TimeInterval = 8 + + /// The in-flight write+verify report; nil when none is running. Doubles as the send() allowlist's + /// in-flight gate — 121 cannot leave the app from this path unless this is non-nil. + private var ecgGateReport: EcgRawDataGateReport? + /// Monotonic step counter so a late timeout can't cancel a newer verification. + private var ecgGateStep = 0 + + /// EXPERIMENTAL (#891): write the device-config key `enable_raw_data_w_ecg` on a WHOOP MG, then READ + /// IT BACK and report what the strap actually stores. + /// + /// ## What this does + /// + /// One `SET_DEVICE_CONFIG_VALUE(119)` write of a single ASCII digit — `'1'` on, `'0'` off — to one + /// named key, followed by one `GET_DEVICE_CONFIG_VALUE(121)` read of the same key. Nothing else is + /// written; the other five keys the strap enumerated are refused by + /// `DeviceConfigWriteGate.admitsSend`, and `SET_FF_VALUE(120)` is unreachable from here. + /// + /// ## Why it might matter + /// + /// #891: all three TOGGLE_LABRADOR (ECG) commands ack SUCCESS on a WHOOP MG and produce zero packets + /// in a 30-second listen. On that same strap this key reads `'0'`. It is the leading candidate for the + /// gate — and **whether flipping it produces ECG data is UNKNOWN**. A confirmed `'1'` with still no + /// packets is a real answer for #891, not a failed attempt. + /// + /// ## Why the read-back is not optional + /// + /// The write's own ack is recorded and NOT believed. #891 established that a result byte can be a + /// read-back of stored state rather than an acknowledgement of a change — SELECT_WRIST returns SUCCESS + /// for a no-op and FAILURE for a real one — so only the 121 read proves anything. (Read-back framing + /// owed to @ryanbr on #891.) + /// + /// ## Gates + /// + /// Opt-in ON, strap positively attested MG over DIS (`.unknown` is not MG — a plain 5.0 has no + /// electrodes), 5/MG family, connected, and the genuine encrypted bond: like the R22 writes (#269), + /// a config write over the live-HR-only link silently fails. Not wear-gated — this stores a value, + /// it does not start an on-wrist stream. Reversible in one call with `on: false`. + public func setEcgRawDataGate(_ on: Bool) { + guard selectedModel.deviceFamily == .whoop5 else { + log("ECG gate (#891): needs a WHOOP 5/MG strap selected — ignored."); return + } + guard PuffinExperiment.ecgRawDataEnabled else { + log("ECG gate (#891): the experiment is off — enable it in Settings → Experimental first."); return + } + let variant = whoop5Variant + guard variant.isMG else { + // Refuse rather than guess. A plain 5.0 has no ECG electrodes, and `.unknown` means the strap + // has not said what it is — either way this key has nothing to gate. + log("ECG gate (#891): the strap has not attested itself an MG over DIS (variant=\(variant.label)) — ignored. A plain WHOOP 5.0 has no ECG electrodes.") + return + } + guard state.connected, state.encryptedBond else { + log("ECG gate (#891): needs the full encrypted bond, not the live-HR-only link. Close the official WHOOP app, put the strap in pairing mode, and bond it to NOOP first — ignored."); return + } + guard ecgGateReport == nil else { + log("ECG gate (#891): a write is already being verified — ignored."); return + } + + ecgGateReport = EcgRawDataGateReport(on: on) + state.ecgRawDataGate = ecgGateReport + log("ECG gate (#891): writing \(DeviceConfigWriteGate.ecgRawDataKey)='\(DeviceConfigWriteGate.valueString(on: on))' via SET_DEVICE_CONFIG_VALUE(119) on an attested MG; the write ack will NOT be reported as the result — a GET_DEVICE_CONFIG_VALUE(121) read-back follows.") + send(.setDeviceConfig, payload: DeviceConfigWriteGate.writePayload(on: on), writeType: .withResponse) + + // Read back after a short settle. 200 ms is the same order the R22 sequence spaces its writes at; + // the strap has to have committed the value before a read can prove anything. + ecgGateStep &+= 1 + let armed = ecgGateStep + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in + guard let self, self.ecgGateReport != nil, self.ecgGateStep == armed else { return } + self.send(.getDeviceConfigValue, payload: DeviceConfigWriteGate.readBackPayload()) + DispatchQueue.main.asyncAfter(deadline: .now() + BLEManager.ecgGateReadBackTimeout) { [weak self] in + guard let self, self.ecgGateReport != nil, self.ecgGateStep == armed else { return } + self.ecgGateReport?.noteReadBackTimeout(seconds: Int(BLEManager.ecgGateReadBackTimeout)) + self.finishEcgGateWrite() + } + } + } + + /// Publish + log the finished write/verify report and re-close the send() allowlist. + private func finishEcgGateWrite() { + guard let report = ecgGateReport else { return } + ecgGateReport = nil + state.ecgRawDataGate = report + log("ECG gate (#891):\n\(report.render())") + } + + /// Clear the #891 result (Settings row dismissed / disconnect). Twin of Android clearEcgRawDataGate(). + public func clearEcgRawDataGate() { state.ecgRawDataGate = nil } + + /// #891: the WRITE's own COMMAND_RESPONSE. Recorded for the transcript and deliberately NOT used to + /// decide the verdict — the 121 read-back below is what settles it. + private func handleEcgGateWriteAck(_ frame: [UInt8], cmdOff: Int) { + guard ecgGateReport != nil else { return } + // pay[1] is the 5/MG result code, at cmdOff + 2 (cmd byte, then the 2-byte response header). + let resultIndex = cmdOff + 2 + let code: Int? = frame.count > resultIndex ? Int(frame[resultIndex]) : nil + ecgGateReport?.noteWriteAck(resultCode: code) + state.ecgRawDataGate = ecgGateReport + } + + /// #891: the read-back COMMAND_RESPONSE for 121. In-flight-guarded, and parsed by the same pure + /// `DeviceConfigReadProbe.parse` the read probe uses — including its CRC gate. + private func handleEcgGateReadBack(_ frame: [UInt8], isWhoop5: Bool) { + guard ecgGateReport != nil else { return } + let family: DeviceFamily = isWhoop5 ? .whoop5 : .whoop4 + switch DeviceConfigReadProbe.parse(frame: frame, family: family, + expecting: DeviceConfigWriteGate.getDeviceConfigValueCmd) { + case .success(let r): ecgGateReport?.noteReadBack(r) + case .failure(let f): ecgGateReport?.noteReadBackFailure(f) + } + finishEcgGateWrite() + } + /// Read the strap's current BLE advertising name (WHOOP 4.0 / Harvard). The reply lands as a /// GET_ADVERTISING_NAME COMMAND_RESPONSE and FrameRouter publishes it to `LiveState.advertisingName`. /// Also sent automatically in the connect handshake; this is the manual refresh the Settings card @@ -2709,6 +2847,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,13 +2862,21 @@ 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()`. - public func 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()`. + /// + /// - Parameter forceCandidateSweep: ask the guessed-name catalogue EVEN IF enumeration succeeded. + /// Off by default because it costs one round-trip per name. It exists because a successful + /// enumeration does not close the question: enumeration reports the keys the firmware HOLDS, a key + /// it would accept but has never stored a value for need not appear, and the oracle cannot tell that + /// case from "no such key" (both answer FAILURE). It also says nothing about the FEATURE-FLAG + /// namespace, where most of the catalogue is aimed — including the `sig` line, the family the + /// strap's own console tag (`SIGPROC: generated a valid SPO2 during sleep`) points at. + public func probeDeviceConfigValues(forceCandidateSweep: Bool = false) { guard state.connected else { log("Device-config read probe (#103) ignored — not connected") return @@ -2744,9 +2895,13 @@ public final class BLEManager: NSObject, ObservableObject { family: selectedModel.deviceFamily, // The flag names come from NOOP's own R22 sequence — never restated here. knownFlagKeys: Whoop5Config.enableR22Sequence.map(\.name), - candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) + batch: ConfigKeySweep.batch(from: configKeySweepCursor), + forceCandidateSweep: forceCandidateSweep) 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)") + let sweepNote = forceCandidateSweep + ? "; FULL SWEEP — the \(ConfigKeySweep.catalogue.count)-name candidate catalogue will be asked even if enumeration succeeds" + : "" + log("Config key probe (#103): enumerating device-config keys via START_DEVICE_CONFIG_KEY_EXCHANGE(115)/SEND_NEXT_DEVICE_CONFIG(116), then reading VALUES via GET_DEVICE_CONFIG_VALUE(121)/GET_FF_VALUE(128) on family=\(selectedModel.deviceFamily)\(sweepNote); read-only (SET_FF_VALUE/120 and SET_DEVICE_CONFIG_VALUE/119 are never sent from this path)") advanceDeviceConfigProbe() } @@ -2767,7 +2922,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 +2947,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 @@ -3216,11 +3405,21 @@ public final class BLEManager: NSObject, ObservableObject { /// information content here) — never the full string, which would land in a shareable strap log. private func noteWhoop5VariantFromDIS() { let variant = Whoop5Variant.from(serial: disSerial, hardwareRevision: disHwRev) + state.whoop5Variant = variant let prefix = (disSerial?.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()) .map { String($0.prefix(3)) } ?? "?" log("DIS: serialPrefix=\(prefix) hwRev=\(disHwRev ?? "?") -> variant=\(variant.label)") } + /// The connected strap's attested 5-generation hardware variant, re-derived from the DIS strings the + /// connection read rather than cached — so it is `.unknown` before DIS lands and after a disconnect + /// clears them, and `.unknown` is never MG. This is the gate an MG-only capability asks (#891); it is + /// deliberately independent of `DeviceFamily`, which describes the WIRE PROTOCOL and treats MG and 5.0 + /// as one family. + public var whoop5Variant: Whoop5Variant { + Whoop5Variant.from(serial: disSerial, hardwareRevision: disHwRev) + } + private func requestNotify(_ c: CBCharacteristic, on p: CBPeripheral, reason: String) { guard c.properties.contains(.notify) || c.properties.contains(.indicate) else { log("Notify unavailable \(c.uuid) (\(reason))") @@ -3844,6 +4043,12 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // …and abandon a plan the link interrupted, re-closing the 121/128 send() allowlist. deviceConfigReport = nil deviceConfigAwaiting = nil + state.ecgRawDataGate = nil // #891: drop a stale write/verify result on disconnect + // …and abandon a verification the link interrupted, re-closing the 119/121 send() allowlist. An + // unverified write must never be left showing a verdict it never reached. + ecgGateReport = nil + // A disconnected strap has attested nothing, so the MG-only gate closes with the link. + state.whoop5Variant = .unknown state.clearBiometrics() // and a stale HR / R-R must not outlive the link either state.liveFeedActive = false // a drop while Live is open must not leave a stale "Stop live feed" didBond = false @@ -4756,6 +4961,18 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { DeviceConfigReadProbe.isReadOnlyOpcode(frame[10]) { handleDeviceConfigProbeResponse(frame, isWhoop5: true) } + // #891: the ECG gate's own two replies — the SET_DEVICE_CONFIG_VALUE(119) write ack + // (recorded, never believed) and the GET_DEVICE_CONFIG_VALUE(121) read-back that is + // the actual proof. Both in-flight-guarded inside, so these are byte compares on every + // other frame. 121 is deliberately handled here as well as by the probe hook above: + // the two paths guard on DIFFERENT in-flight sentinels, so exactly one of them acts. + if frame.count > 10, frame[8] == 0x24 { + if frame[10] == WhoopCommand.setDeviceConfig.rawValue { + handleEcgGateWriteAck(frame, cmdOff: 10) + } else if frame[10] == WhoopCommand.getDeviceConfigValue.rawValue { + handleEcgGateReadBack(frame, isWhoop5: true) + } + } // #695: a 5/MG GET_DATA_RANGE COMMAND_RESPONSE (puffin envelope: type @8, cmd @10). Feeds // the SAME newest/oldest window + backfill gate + diagnostics as the 4.0 path above — this // reply was previously ignored on 5/MG (the 4.0 handler keyed on frame[6]). cmdOff = 10. diff --git a/Strand/BLE/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/Strand/BLE/LiveState.swift b/Strand/BLE/LiveState.swift index 46b3fb2122..dd40055d87 100644 --- a/Strand/BLE/LiveState.swift +++ b/Strand/BLE/LiveState.swift @@ -256,6 +256,18 @@ public final class LiveState: ObservableObject { /// sentinel while the walk runs. Nothing is written to the strap to produce it. Cleared on disconnect /// and on dialog dismiss. Twin of the Android WhoopBleClient.deviceConfigProbe flow. @Published public var deviceConfigProbe: String? = nil + + /// #891: the result of the last `enable_raw_data_w_ecg` write, AFTER its mandatory + /// `GET_DEVICE_CONFIG_VALUE(121)` read-back — the write's own ack is never reported as the outcome. + /// nil until a write is attempted; cleared on disconnect. Twin of the Android + /// WhoopBleClient.ecgRawDataGate flow. + @Published public var ecgRawDataGate: EcgRawDataGateReport? = nil + + /// #520/#891: which WHOOP 5-generation hardware the connected strap attested itself over the Device + /// Information Service. `.unknown` until the DIS strings land — and `.unknown` is NOT MG, so an + /// MG-only action stays refused until the hardware actually says so. Reset on disconnect. + @Published public var whoop5Variant: Whoop5Variant = .unknown + /// Wrist-wear state from WRIST_ON/WRIST_OFF events. Defaults true so wear-gated features work /// before the first event arrives; flipped by FrameRouter on a real event. @Published public var worn: Bool = true diff --git a/Strand/BLE/PuffinExperiment.swift b/Strand/BLE/PuffinExperiment.swift index 4cb7c005e2..c15a71e4bd 100644 --- a/Strand/BLE/PuffinExperiment.swift +++ b/Strand/BLE/PuffinExperiment.swift @@ -30,6 +30,25 @@ enum PuffinExperiment { static var broadcastHrEnabled: Bool { UserDefaults.standard.bool(forKey: broadcastHrKey) } + /// Opt-in "ECG raw-data gate" (#891): writes the device-config key `enable_raw_data_w_ecg` — the key + /// the strap's own 115/116 enumeration listed, and which reads `'0'` on a subscription-free WHOOP MG + /// whose three TOGGLE_LABRADOR commands all ack SUCCESS and emit nothing. + /// + /// Its own key rather than a shared "ECG" one, for two reasons. First, this repo gives every + /// PERSISTENT STRAP WRITE its own deliberate opt-in — `deepDataKey` (#174) and `broadcastHrKey` (#181) + /// are both separate from the read-only `defaultsKey` probes for exactly that reason, and reusing one + /// switch for "listen for ECG packets" and "change a stored value on the strap" would let the second + /// ride in on consent given for the first. Second, the ECG listen toggles are not on main: they live on + /// an unmerged branch, so there is no existing key here to reuse. + /// + /// Reversible in one tap, default OFF, and additionally gated on `Whoop5Variant.isMG` at the call site + /// — a plain 5.0 has no electrodes. Driven only by `BLEManager.setEcgRawDataGate(_:)`, which always + /// follows the write with a `GET_DEVICE_CONFIG_VALUE(121)` read-back. Mirrors the Android + /// `PuffinExperiment.KEY_ECG_RAW_DATA`. + static let ecgRawDataKey = "noopEcgRawDataGate" + + static var ecgRawDataEnabled: Bool { UserDefaults.standard.bool(forKey: ecgRawDataKey) } + /// Opt-in "Continuous HRV capture": hold the dense realtime HR stream armed even with no Live screen /// open, so the strap banks beat-to-beat R-R intervals 24/7 for far better overnight HRV/recovery/ /// sleep (vs the sparse history offload). Uses more battery (continuous HR streaming). Default OFF; @@ -152,6 +171,7 @@ enum PuffinExperiment { defaultsKey, // protocol probes deepDataKey, // R22 deep-data strap write broadcastHrKey, // broadcast-HR write + ecgRawDataKey, // enable_raw_data_w_ecg strap write (#891) PuffinFrameRecorder.enabledKey, // raw frame capture — declared on PuffinFrameRecorder ] diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index f26bf7870c..eb3adfa23c 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -52687,6 +52687,58 @@ } } }, + "ECG raw-data gate (WHOOP MG only)": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "ECG-Rohdatensperre (nur WHOOP MG)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Bloqueo de datos ECG sin procesar (solo WHOOP MG)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Verrou des données ECG brutes (WHOOP MG uniquement)" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Bloqueio de dados ECG em bruto (apenas WHOOP MG)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Blocco dei dati ECG grezzi (solo WHOOP MG)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Блокировка сырых данных ЭКГ (только WHOOP MG)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "ECG 原始数据开关(仅限 WHOOP MG)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "ECG 原始資料開關(僅限 WHOOP MG)" + } + } + } + }, "EFFORT": { "localizations": { "de": { @@ -123002,6 +123054,58 @@ } } }, + "Send probe + full name sweep (read-only, slower)": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Probe + vollständiger Namensdurchlauf senden (nur lesend, langsamer)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar sonda + barrido completo de nombres (solo lectura, más lento)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer la sonde + balayage complet des noms (lecture seule, plus lent)" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Enviar sonda + varrimento completo de nomes (apenas leitura, mais lento)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Invia sonda + scansione completa dei nomi (sola lettura, più lenta)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправить пробу + полный перебор имён (только чтение, медленнее)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发送探测 + 完整名称扫描(只读,较慢)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "傳送探測 + 完整名稱掃描(唯讀,較慢)" + } + } + } + }, "Sends the read-only GET_BODY_LOCATION_AND_STATUS (0x54) and shows the strap's full raw reply, decoding the body-location record (revision / location / confidence / status) on WHOOP 4.0. Nothing is written to the strap, and it never changes wear detection or scoring.": { "localizations": { "de": { @@ -141477,6 +141581,58 @@ } } }, + "This writes a setting that STAYS ON YOUR STRAP until you change it back — it isn't an app preference, and closing NOOP won't undo it. \"Turn gate off\" below writes '0' again, in one tap. Only this one key is ever written; the other six your strap listed are never touched.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dies schreibt eine Einstellung, die AUF DEINEM BAND BLEIBT, bis du sie zurücksetzt — es ist keine App-Einstellung, und das Schließen von NOOP macht sie nicht rückgängig. „Sperre ausschalten“ unten schreibt mit einem Tippen wieder '0'. Es wird ausschließlich dieser eine Schlüssel geschrieben; die anderen sechs, die dein Band aufgelistet hat, werden nie angefasst." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto escribe un ajuste que PERMANECE EN TU CORREA hasta que lo cambies de vuelta: no es una preferencia de la app y cerrar NOOP no lo deshace. «Desactivar bloqueo», abajo, vuelve a escribir '0' con un solo toque. Solo se escribe esta clave; las otras seis que enumeró tu correa nunca se tocan." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ceci écrit un réglage qui RESTE SUR VOTRE BRACELET jusqu'à ce que vous le remettiez — ce n'est pas une préférence de l'app, et fermer NOOP ne l'annule pas. « Désactiver le verrou », ci-dessous, réécrit « 0 » en une seule touche. Seule cette clé est écrite ; les six autres que votre bracelet a énumérées ne sont jamais touchées." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Isto escreve uma definição que FICA NA SUA PULSEIRA até a repor — não é uma preferência da aplicação e fechar o NOOP não a desfaz. «Desativar bloqueio», abaixo, volta a escrever '0' com um só toque. Só esta chave é escrita; as outras seis que a sua pulseira listou nunca são tocadas." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questo scrive un'impostazione che RESTA SULLA TUA FASCIA finché non la riporti indietro: non è una preferenza dell'app e chiudere NOOP non la annulla. «Disattiva blocco», qui sotto, riscrive '0' con un solo tocco. Viene scritta solo questa chiave; le altre sei elencate dalla fascia non vengono mai toccate." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Это записывает настройку, которая ОСТАЁТСЯ НА БРАСЛЕТЕ, пока вы не вернёте её обратно, — это не настройка приложения, и закрытие NOOP её не отменит. Кнопка «Выключить блокировку» ниже одним нажатием записывает '0' снова. Записывается только этот один ключ; остальные шесть, перечисленные браслетом, никогда не затрагиваются." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这会写入一项会一直留在你手环上的设置,直到你改回来——它不是应用内的偏好设置,关闭 NOOP 也不会撤销。下方的「关闭开关」一键即可再写入 '0'。全程只写入这一个键;你手环列出的另外六个从不触碰。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這會寫入一項會一直留在你手環上的設定,直到你改回來——它不是應用程式內的偏好設定,關閉 NOOP 也不會復原。下方的「關閉開關」一鍵即可再寫入 '0'。全程只寫入這一個鍵;你手環列出的另外六個從不觸碰。" + } + } + } + }, "Three quiet promises.": { "localizations": { "de": { @@ -144639,6 +144795,110 @@ } } }, + "Turn gate off (write '0')": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sperre ausschalten ('0' schreiben)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivar bloqueo (escribir '0')" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactiver le verrou (écrire « 0 »)" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Desativar bloqueio (escrever '0')" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Disattiva blocco (scrivi '0')" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выключить блокировку (записать '0')" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭开关(写入 '0')" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉開關(寫入 '0')" + } + } + } + }, + "Turn gate on (write '1')": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sperre einschalten ('1' schreiben)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activar bloqueo (escribir '1')" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer le verrou (écrire « 1 »)" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Ativar bloqueio (escrever '1')" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Attiva blocco (scrivi '1')" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить блокировку (записать '1')" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开开关(写入 '1')" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟開關(寫入 '1')" + } + } + } + }, "Turn off": { "localizations": { "de": { @@ -159021,6 +159281,58 @@ } } }, + "Your strap listed its own device-config keys, and one of them is enable_raw_data_w_ecg. On an MG with no ECG subscription it reads '0' — while all three ECG commands answer SUCCESS and send no data at all (#891). This is the leading guess for what's holding ECG shut. Nobody knows whether flipping it actually produces ECG data: finding out is the point, and \"still nothing\" is a useful answer worth posting to #891.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dein Band hat seine eigenen Geräte-Konfigurationsschlüssel aufgelistet, darunter enable_raw_data_w_ecg. Auf einem MG ohne ECG-Abo steht er auf '0' — während alle drei ECG-Befehle mit SUCCESS antworten und trotzdem keine Daten senden (#891). Das ist die naheliegendste Vermutung, was ECG verschlossen hält. Ob das Umlegen tatsächlich ECG-Daten liefert, weiß niemand: genau das herauszufinden ist der Zweck, und „immer noch nichts“ ist ein brauchbares Ergebnis, das sich zu posten lohnt (#891)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tu correa ha enumerado sus propias claves de configuración, y una de ellas es enable_raw_data_w_ecg. En un MG sin suscripción de ECG vale '0', mientras que los tres comandos de ECG responden SUCCESS y no envían dato alguno (#891). Es la hipótesis principal sobre qué mantiene el ECG cerrado. Nadie sabe si cambiarla produce realmente datos de ECG: averiguarlo es el objetivo, y «sigue sin haber nada» es una respuesta útil que merece publicarse en #891." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Votre bracelet a énuméré ses propres clés de configuration, dont enable_raw_data_w_ecg. Sur un MG sans abonnement ECG, elle vaut « 0 » — alors que les trois commandes ECG répondent SUCCESS sans envoyer la moindre donnée (#891). C'est l'hypothèse principale sur ce qui maintient l'ECG fermé. Personne ne sait si la basculer produit réellement des données ECG : le savoir est justement le but, et « toujours rien » est une réponse utile à publier sur #891." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A sua pulseira listou as suas próprias chaves de configuração, e uma delas é enable_raw_data_w_ecg. Num MG sem subscrição de ECG lê '0' — enquanto os três comandos de ECG respondem SUCCESS e não enviam dados nenhuns (#891). Esta é a principal hipótese para o que mantém o ECG fechado. Ninguém sabe se mudá-la produz mesmo dados de ECG: descobrir é o objetivo, e «continua sem nada» é uma resposta útil que vale a pena publicar em #891." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La tua fascia ha elencato le proprie chiavi di configurazione, e una di esse è enable_raw_data_w_ecg. Su un MG senza abbonamento ECG vale '0', mentre tutti e tre i comandi ECG rispondono SUCCESS senza inviare alcun dato (#891). È l'ipotesi principale su cosa tenga chiuso l'ECG. Nessuno sa se cambiarla produca davvero dati ECG: scoprirlo è lo scopo, e «ancora nulla» è una risposta utile da pubblicare su #891." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ваш браслет перечислил собственные ключи конфигурации, и один из них — enable_raw_data_w_ecg. На MG без подписки на ЭКГ он равен '0', при этом все три команды ЭКГ отвечают SUCCESS и не присылают никаких данных (#891). Это главная догадка о том, что держит ЭКГ закрытым. Никто не знает, даст ли переключение реальные данные ЭКГ: выяснить это и есть цель, а «по-прежнему ничего» — полезный результат, который стоит опубликовать в #891." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你的手环列出了它自己的设备配置键,其中之一是 enable_raw_data_w_ecg。在没有 ECG 订阅的 MG 上它读作 '0',而三条 ECG 命令都回应 SUCCESS 却完全不发送数据 (#891)。这是目前对「什么在挡住 ECG」最有力的猜测。翻转它是否真能产生 ECG 数据,没有人知道:弄清楚正是目的,而「仍然没有」同样是值得发到 #891 的有用答案。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你的手環列出了它自己的裝置設定鍵,其中之一是 enable_raw_data_w_ecg。在沒有 ECG 訂閱的 MG 上它讀作 '0',而三條 ECG 命令都回應 SUCCESS 卻完全不傳送資料 (#891)。這是目前對「什麼擋住 ECG」最有力的猜測。翻轉它是否真能產生 ECG 資料,沒有人知道:弄清楚正是目的,而「仍然沒有」同樣是值得發到 #891 的有用答案。" + } + } + } + }, "Your strap wake-alarm and the evening wind-down reminder, in one place.": { "localizations": { "de": { diff --git a/Strand/Screens/DevicesView.swift b/Strand/Screens/DevicesView.swift index 6629c5156d..aa558c27c6 100644 --- a/Strand/Screens/DevicesView.swift +++ b/Strand/Screens/DevicesView.swift @@ -1193,8 +1193,16 @@ private struct DeviceConfigProbeSheets: ViewModifier { titleVisibility: .visible, presenting: target) { _ in Button("Send probe (read-only)") { model.probeDeviceConfigValues(); target = nil } + // The forced sweep is its own explicit choice, never a fallback the probe takes on its + // own: it spends one round-trip per catalogue name, and a strap that enumerated cleanly + // would otherwise skip all of them. Still read-only — same two verbs, same allowlist. + Button("Send probe + full name sweep (read-only, slower)") { + model.probeDeviceConfigValues(forceCandidateSweep: true); target = nil + } Button("Cancel", role: .cancel) { target = nil } } message: { _ in + // Unchanged copy: editing it would orphan its existing translations in every locale, and + // what the full sweep adds is already carried by the button label + the report header. Text("Asks the strap for config VALUES: GET_DEVICE_CONFIG_VALUE (0x79) and GET_FF_VALUE (0x80), one key per round-trip. Both commands may simply not exist in this firmware — finding that out is the point. Read-only — no value is written, and the SET commands are never sent from this probe. The result is shown here and written to the strap log.") } .sheet(isPresented: Binding(get: { live.deviceConfigProbe != nil }, diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index c183c6ef0a..afce43880a 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -10,6 +10,9 @@ import PhotosUI import StrandDesign import StrandAnalytics import WhoopStore +// #891: EcgRawDataGateReport / Whoop5Variant — the ECG-gate row reports the strap's read-back verdict and +// gates on the attested hardware variant. +import WhoopProtocol /// Settings — profile (powers zones / calories / recovery), strap connection, and about. /// Grouped cards on surface.raised with a two-column form feel. @@ -41,6 +44,11 @@ struct SettingsView: View { /// BLE sensor for Garmin/Zwift/gym kit. See [PuffinExperiment.broadcastHrKey]. (#181) @AppStorage(PuffinExperiment.broadcastHrKey) private var broadcastHrEnabled = false + /// #891 opt-in: writes the device-config key `enable_raw_data_w_ecg` on an attested WHOOP MG. A + /// persistent strap write, so it gets its own deliberate switch like #174 and #181. + /// See [PuffinExperiment.ecgRawDataKey]. + @AppStorage(PuffinExperiment.ecgRawDataKey) private var ecgRawDataEnabled = false + /// Opt-in "Continuous HRV capture" (off by default) — holds the dense realtime stream armed 24/7 so /// the strap banks beat-to-beat R-R for better overnight HRV/recovery/sleep, at a battery cost. /// See [PuffinExperiment.keepRealtimeForDataKey]. @@ -1405,6 +1413,52 @@ struct SettingsView: View { #endif } + /// The #891 ECG-gate buttons need the same encrypted bond the R22 writes do (a config write over the + /// live-HR-only link silently fails, #269) AND a strap that has positively attested itself an MG. Not + /// wear-gated: this stores a value, it does not start an on-wrist stream. + private var ecgGateReady: Bool { + #if os(macOS) + return false + #else + return live.encryptedBond && live.whoop5Variant.isMG + #endif + } + + /// The reason line under the #891 buttons. Each case names the ONE thing that is missing. + private var ecgGateReason: String { + #if os(macOS) + return String(localized: "The ECG gate needs an iPhone or Android. A Mac can't form the encrypted bond a 5/MG requires.") + #else + if !live.encryptedBond { + return String(localized: "Needs the full encrypted bond: close the official WHOOP app and pair the strap to NOOP first (a live-HR-only link can't carry a config write).") + } + if !live.whoop5Variant.isMG { + // `.unknown` lands here too, and deliberately: an unattested strap is not an MG. + return String(localized: "Waiting for your strap to identify itself as an MG. Only a WHOOP MG has ECG electrodes, so NOOP won't write this key to anything else.") + } + return String(localized: "One tap writes the key; NOOP then reads it back off the strap and reports the value it actually stores — the write's own \"success\" is not treated as proof.") + #endif + } + + /// Icon per read-back verdict. Only a confirmed read-back gets the success mark. + private func ecgGateIcon(_ v: EcgRawDataGateReport.Verdict) -> String { + switch v { + case .confirmed: return "checkmark.seal.fill" + case .unchanged: return "xmark.seal.fill" + case .pending: return "ellipsis" + default: return "questionmark.circle" + } + } + + /// Tint per read-back verdict. Anything that isn't a confirmed read-back is never shown as positive. + private func ecgGateTint(_ v: EcgRawDataGateReport.Verdict) -> Color { + switch v { + case .confirmed: return StrandPalette.statusPositive + case .unchanged: return StrandPalette.statusWarning + default: return StrandPalette.textSecondary + } + } + private var fiveMGCard: some View { SettingsSection( icon: "flask.fill", @@ -1503,6 +1557,57 @@ struct SettingsView: View { .accessibilityElement(children: .combine) } + Divider().overlay(StrandPalette.hairline) + + // MARK: #891 ECG raw-data gate — the second key this app may write, and MG-only. + Toggle(isOn: $ecgRawDataEnabled) { + Text("ECG raw-data gate (WHOOP MG only)") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + } + .toggleStyle(.switch) + .tint(StrandPalette.accent) + Text("Your strap listed its own device-config keys, and one of them is enable_raw_data_w_ecg. On an MG with no ECG subscription it reads '0' — while all three ECG commands answer SUCCESS and send no data at all (#891). This is the leading guess for what's holding ECG shut. Nobody knows whether flipping it actually produces ECG data: finding out is the point, and \"still nothing\" is a useful answer worth posting to #891.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + + if ecgRawDataEnabled { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(StrandPalette.statusWarning) + .accessibilityHidden(true) + Text("This writes a setting that STAYS ON YOUR STRAP until you change it back — it isn't an app preference, and closing NOOP won't undo it. \"Turn gate off\" below writes '0' again, in one tap. Only this one key is ever written; the other six your strap listed are never touched.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.statusWarning) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + + NoopButton("Turn gate on (write '1')", systemImage: "waveform.path.ecg", kind: .primary) { + model.ble.setEcgRawDataGate(true) + } + .disabled(!ecgGateReady) + NoopButton("Turn gate off (write '0')", systemImage: "arrow.uturn.backward", kind: .secondary) { + model.ble.setEcgRawDataGate(false) + } + .disabled(!ecgGateReady) + Text(ecgGateReason) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + + // The read-back — the only thing reported as a result. The write's own ack is in the + // strap log for the record and is deliberately not surfaced as an outcome here. + if let report = live.ecgRawDataGate { + Label(report.summary, systemImage: ecgGateIcon(report.verdict)) + .font(StrandFont.caption) + .foregroundStyle(ecgGateTint(report.verdict)) + .fixedSize(horizontal: false, vertical: true) + } + } + Toggle(isOn: $puffinCapture) { Text("Record puffin frames to a file") .font(StrandFont.subhead) diff --git a/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt b/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt index 8f070188c0..a381cdf3cb 100644 --- a/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt +++ b/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt @@ -46,6 +46,23 @@ class PuffinExperiment(private val prefs: SharedPreferences) { get() = prefs.getBoolean(KEY_BROADCAST_HR, false) set(v) = prefs.edit().putBoolean(KEY_BROADCAST_HR, v).apply() + /** True if the user opted in to the "ECG raw-data gate" (#891): NOOP writes the device-config key + * `enable_raw_data_w_ecg` — the key the strap's own 115/116 enumeration listed, and which reads '0' on + * a subscription-free WHOOP MG whose three TOGGLE_LABRADOR commands all ack SUCCESS and emit nothing. + * + * Its own key rather than a shared "ECG" one, because this repo gives every PERSISTENT STRAP WRITE its + * own deliberate opt-in ([isDeepDataEnabled] #174, [broadcastHr] #181) — reusing one switch for + * "listen for ECG packets" and "change a stored value on the strap" would let the second ride in on + * consent given for the first. + * + * Reversible in one tap, default false, and additionally gated on `Whoop5Variant.isMG` at the call + * site — a plain 5.0 has no electrodes. Driven only by `WhoopBleClient.setEcgRawDataGate`, which always + * follows the write with a GET_DEVICE_CONFIG_VALUE(121) read-back. Mirrors the macOS + * `PuffinExperiment.ecgRawDataKey`. */ + var ecgRawData: Boolean + get() = prefs.getBoolean(KEY_ECG_RAW_DATA, false) + set(v) = prefs.edit().putBoolean(KEY_ECG_RAW_DATA, v).apply() + /** True if the user opted in to "Experimental sleep staging (V2)": detected nights are re-staged with * [com.noop.analytics.SleepStagerV2] (the transparent cardiorespiratory recipe, reimplemented from * contributor PR #600) instead of the older V1 [com.noop.analytics.SleepStager]. Pure analysis switch @@ -131,9 +148,14 @@ class PuffinExperiment(private val prefs: SharedPreferences) { /** "Broadcast heart rate" opt-in (mirrors macOS `PuffinExperiment.broadcastHrKey`). */ const val KEY_BROADCAST_HR = "noopBroadcastHr" + /** "ECG raw-data gate" opt-in — the `enable_raw_data_w_ecg` strap write (mirrors macOS + * `PuffinExperiment.ecgRawDataKey`). (#891) */ + const val KEY_ECG_RAW_DATA = "noopEcgRawDataGate" + /** The 5/MG-only probe keys, in ONE place: [resetFiveMGGatedProbes] clears exactly these, and * SettingsScreen watches exactly these for external writes. Two lists would drift. */ - internal val FIVE_MG_GATED_KEYS = listOf(KEY, KEY_CAPTURE, KEY_DEEP_DATA, KEY_BROADCAST_HR) + internal val FIVE_MG_GATED_KEYS = + listOf(KEY, KEY_CAPTURE, KEY_DEEP_DATA, KEY_BROADCAST_HR, KEY_ECG_RAW_DATA) /** "Experimental sleep staging (V2)" opt-in (mirrors macOS `PuffinExperiment.experimentalSleepV2Key`). */ const val KEY_EXPERIMENTAL_SLEEP_V2 = "noopExperimentalSleepV2" diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index be009f1544..af631025fd 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -37,9 +37,12 @@ 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 +import com.noop.protocol.DeviceConfigWriteGate +import com.noop.protocol.EcgRawDataGateReport import com.noop.protocol.FeatureFlagProbe import com.noop.protocol.FeatureFlagProbeReport import com.noop.protocol.Framing @@ -1215,6 +1218,14 @@ class WhoopBleClient( * out), so this bounds one round-trip, not the whole plan. */ const val DEVICE_CONFIG_PROBE_TIMEOUT_MS = 8_000L + /** #891: settle before the ECG gate's read-back. Same order the R22 sequence spaces its writes at; + * the strap has to have committed the value before a read can prove anything. */ + const val ECG_GATE_SETTLE_MS = 200L + + /** #891: reply window for the ECG gate's read-back. One round-trip, so this bounds the whole + * verification. */ + const val ECG_GATE_READ_BACK_TIMEOUT_MS = 8_000L + /** * #690: format a GET_BODY_LOCATION_AND_STATUS (0x54) COMMAND_RESPONSE into a clean, readable, * copyable report — verdict, full raw hex, an offset-labelled payload grid, the four decoded fields @@ -1540,6 +1551,26 @@ class WhoopBleClient( private val _deviceConfigProbe = MutableStateFlow(null) val deviceConfigProbe: StateFlow = _deviceConfigProbe.asStateFlow() + /** #891: the result of the last `enable_raw_data_w_ecg` write, AFTER its mandatory + * GET_DEVICE_CONFIG_VALUE(121) read-back — the write's own ack is never reported as the outcome. + * null until a write is attempted; cleared on disconnect. Twin of the Swift + * `LiveState.ecgRawDataGate`. */ + private val _ecgRawDataGate = MutableStateFlow(null) + val ecgRawDataGate: StateFlow = _ecgRawDataGate.asStateFlow() + + /** #520/#891: which WHOOP 5-generation hardware the connected strap attested over DIS. UNKNOWN until + * the strings land — and UNKNOWN is NOT MG, so an MG-only action stays refused until the hardware + * actually says so. Reset on disconnect. */ + private val _whoop5Variant = MutableStateFlow(Whoop5Variant.UNKNOWN) + val whoop5VariantFlow: StateFlow = _whoop5Variant.asStateFlow() + + /** The in-flight #891 write+verify report; null when none is running. Doubles as the [send] allowlist's + * in-flight gate — 121 cannot leave the app from this path unless this is non-null. */ + private var ecgGateReport: EcgRawDataGateReport? = null + + /** Monotonic step counter so a late timeout can't cancel a newer verification. */ + private var ecgGateStep = 0 + /** The in-flight #103 report; null when no probe is running. Doubles as the [send] allowlist's * in-flight gate — 121/128 cannot leave the app unless this is non-null. */ private var deviceConfigReport: DeviceConfigReadProbeReport? = null @@ -1548,6 +1579,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,21 +2891,45 @@ 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 // on a default install. Reversible; driven only by enableWhoop5DeepData(). (#174) !(cmd == CommandNumber.SET_CONFIG && puffinExperiment.isDeepDataEnabled) && - // SET_DEVICE_CONFIG (the Broadcast-HR flag) is allowed ONLY while that opt-in is on. - // Reversible; driven only by setBroadcastHr(). (#181) - !(cmd == CommandNumber.SET_DEVICE_CONFIG && puffinExperiment.broadcastHr)) { + // SET_DEVICE_CONFIG_VALUE (119) writes ONE persistent device-config value. Opcode 119 is + // shared by more than one feature, so an opcode-only clause cannot say "this key and no + // other" — and the clause this replaced admitted ANY device-config key whenever the + // Broadcast-HR opt-in happened to be on. DeviceConfigWriteGate.admitsSend parses the key + // NAME out of the body and admits exactly two, each only while its OWN opt-in is on: + // whoop_live_hr_in_adv_ind_pkt (#181, driven by setBroadcastHr()) and + // enable_raw_data_w_ecg (#891, driven by setEcgRawDataGate(), which additionally requires + // the strap to have attested itself an MG). The other five keys the strap's own 115/116 + // enumeration listed are refused unconditionally, and SET_FF_VALUE(120) is refused by this + // predicate outright — the R22 sequence keeps its separate clause above. Same discipline as + // DeviceConfigReadProbe.isReadOnlyOpcode: ONE pure predicate that the send path itself + // consults, so the unit tests that prove what it rejects are proving it about this wire + // path rather than about a copy of the rule. + !DeviceConfigWriteGate.admitsSend( + opcode = cmd.rawValue, + payload = payload, + ecgGateOptIn = puffinExperiment.ecgRawData, + isMG = whoop5Variant().isMG, + broadcastHrOptIn = puffinExperiment.broadcastHr, + ) && + // GET_DEVICE_CONFIG_VALUE (121) as the ECG gate's mandatory post-write read-back. Allowed + // ONLY while a verification is actually in flight, the same in-flight shape the read probes + // use, and narrowed to 121 alone (isReadBackOpcode) rather than the probe's four. + !(DeviceConfigWriteGate.isReadBackOpcode(cmd.rawValue) && ecgGateReport != null)) { log("send(${cmd.name}) skipped — no WHOOP 5/MG framing for this command yet") return } @@ -3379,8 +3441,16 @@ class WhoopBleClient( * of guessed oxygen key names. The report goes to the Devices dialog and the strap log — no new * storage. User-initiated only, Test Centre → Connection gated at the call site. Twin of macOS * BLEManager.probeDeviceConfigValues(). + * + * @param forceCandidateSweep ask the guessed-name catalogue EVEN IF enumeration succeeded. Off by + * default because it costs one round-trip per name per answering verb. It exists because a + * successful enumeration does not close the question: enumeration reports the keys the firmware + * HOLDS, a key it would accept but has never stored a value for need not appear, and the oracle + * cannot tell that case from "no such key" (both answer FAILURE). It also says nothing about the + * FEATURE-FLAG namespace, where most of the catalogue is aimed — including the `sig` line, the + * family the strap's own console tag ("SIGPROC: generated a valid SPO2 during sleep") points at. */ - fun probeDeviceConfigValues() { + fun probeDeviceConfigValues(forceCandidateSweep: Boolean = false) { if (!_state.value.connected) { log("Device-config read probe (#103) ignored — not connected") return @@ -3399,13 +3469,21 @@ 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), + forceCandidateSweep, ) _deviceConfigProbe.value = WAITING_DEVICE_CONFIG_PROBE + val sweepNote = if (forceCandidateSweep) { + "; FULL SWEEP — the ${ConfigKeySweep.CATALOGUE.size}-name candidate catalogue will be asked " + + "even if enumeration succeeds" + } else { + "" + } log( - "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$sweepNote; " + + "read-only (SET_FF_VALUE/120 and SET_DEVICE_CONFIG_VALUE/119 are never sent from this path)", ) advanceDeviceConfigProbe() } @@ -3428,7 +3506,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 +3531,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 +3552,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 @@ -3620,18 +3742,27 @@ class WhoopBleClient( } /** - * Resolve + log the 5/MG hardware variant from whatever DIS strings have landed (#520). Diagnostic - * only — nothing gates on it yet. + * Resolve + log the 5/MG hardware variant from whatever DIS strings have landed (#520). * * The serial is a device identifier, so ONLY its 3-character prefix is logged (that is the entire * information content here) — never the full string, which would end up in a shareable strap log. */ private fun noteWhoop5VariantFromDis() { - val variant = Whoop5Variant.from(disSerial, disHwRev) + val variant = whoop5Variant() + _whoop5Variant.value = variant val prefix = disSerial?.trim()?.uppercase()?.take(3) ?: "?" log("DIS: serialPrefix=$prefix hwRev=${disHwRev ?: "?"} -> variant=${variant.label}") } + /** + * The connected strap's attested 5-generation hardware variant, re-derived from the DIS strings the + * connection read rather than cached — so it is UNKNOWN before DIS lands and after a disconnect clears + * them, and UNKNOWN is never MG. This is the gate an MG-only capability asks (#891); it is deliberately + * independent of [DeviceFamily], which describes the WIRE PROTOCOL and treats MG and 5.0 as one family. + * Mirrors the Swift `BLEManager.whoop5Variant`. + */ + fun whoop5Variant(): Whoop5Variant = Whoop5Variant.from(disSerial, disHwRev) + fun refreshBattery() { val g = gatt if (g == null) { @@ -4813,6 +4944,19 @@ class WhoopBleClient( ) { handleDeviceConfigProbeResponse(frame) } + // #891: the ECG gate's own two replies — the SET_DEVICE_CONFIG_VALUE(119) write ack + // (recorded, never believed) and the GET_DEVICE_CONFIG_VALUE(121) read-back that is the + // actual proof. Both in-flight-guarded inside, so these are byte compares on every other + // frame. 121 is deliberately handled here as well as by the probe hook above: the two + // paths guard on DIFFERENT in-flight sentinels, so exactly one of them acts. + if (frame.size > cmdOff && (frame[cmdOff - 2].toInt() and 0xFF) == 0x24) { + val op = frame[cmdOff].toInt() and 0xFF + if (op == CommandNumber.SET_DEVICE_CONFIG.rawValue) { + handleEcgGateWriteAck(frame, cmdOff) + } else if (op == CommandNumber.GET_DEVICE_CONFIG_VALUE.rawValue) { + handleEcgGateReadBack(frame, connectedFamily == DeviceFamily.WHOOP5) + } + } if (frame.size > cmdOff && (frame[cmdOff].toInt() and 0xFF) == CommandNumber.GET_DATA_RANGE.rawValue) { // #451: dump raw GET_DATA_RANGE response bytes unconditionally (even if decode returns // null) so a stale/wrong-epoch "newest" can be told apart from a frame-alignment bug in @@ -5615,6 +5759,126 @@ class WhoopBleClient( log("Broadcast HR: wrote whoop_live_hr_in_adv_ind_pkt=" + (if (on) "1" else "0")) } + /** + * EXPERIMENTAL (#891): write the device-config key `enable_raw_data_w_ecg` on a WHOOP MG, then READ IT + * BACK and report what the strap actually stores. Mirrors `BLEManager.setEcgRawDataGate`. + * + * One SET_DEVICE_CONFIG_VALUE(119) write of a single ASCII digit — '1' on, '0' off — to one named key, + * followed by one GET_DEVICE_CONFIG_VALUE(121) read of the same key. Nothing else is written: the other + * five keys the strap enumerated are refused by [DeviceConfigWriteGate.admitsSend], and SET_FF_VALUE + * (120) is unreachable from here. + * + * #891: all three TOGGLE_LABRADOR (ECG) commands ack SUCCESS on a WHOOP MG and produce zero packets in + * a 30-second listen. On that same strap this key reads '0'. It is the leading candidate for the gate — + * and **whether flipping it produces ECG data is UNKNOWN**. A confirmed '1' with still no packets is a + * real answer for #891, not a failed attempt. + * + * The write's own ack is recorded and NOT believed: #891 established that a result byte can be a + * read-back of stored state rather than an acknowledgement of a change, so only the 121 read proves + * anything. + * + * Gates: opt-in ON, strap positively attested MG over DIS (UNKNOWN is not MG — a plain 5.0 has no + * electrodes), 5/MG family, connected and bonded. Not wear-gated: this stores a value, it does not start + * an on-wrist stream. Reversible in one call with `on = false`. + */ + fun setEcgRawDataGate(on: Boolean) { + if (connectedFamily != DeviceFamily.WHOOP5) { + log("ECG gate (#891): needs a WHOOP 5/MG strap — ignored."); return + } + if (!puffinExperiment.ecgRawData) { + log("ECG gate (#891): the experiment is off — enable it in Settings → Experimental first.") + return + } + val variant = whoop5Variant() + if (!variant.isMG) { + // Refuse rather than guess. A plain 5.0 has no ECG electrodes, and UNKNOWN means the strap has + // not said what it is — either way this key has nothing to gate. + log( + "ECG gate (#891): the strap has not attested itself an MG over DIS " + + "(variant=${variant.label}) — ignored. A plain WHOOP 5.0 has no ECG electrodes.", + ) + return + } + val s = _state.value + if (!s.connected || !s.bonded) { + log("ECG gate (#891): connect and bond a 5/MG strap first — ignored."); return + } + if (ecgGateReport != null) { + log("ECG gate (#891): a write is already being verified — ignored."); return + } + + val report = EcgRawDataGateReport(on) + ecgGateReport = report + _ecgRawDataGate.value = report + log( + "ECG gate (#891): writing ${DeviceConfigWriteGate.ECG_RAW_DATA_KEY}=" + + "'${DeviceConfigWriteGate.valueString(on)}' via SET_DEVICE_CONFIG_VALUE(119) on an " + + "attested MG; the write ack will NOT be reported as the result — a " + + "GET_DEVICE_CONFIG_VALUE(121) read-back follows.", + ) + send( + CommandNumber.SET_DEVICE_CONFIG, + DeviceConfigWriteGate.writePayload(on), + withResponse = true, + ) + + // Read back after a short settle. 200 ms is the same order the R22 sequence spaces its writes at; + // the strap has to have committed the value before a read can prove anything. + ecgGateStep += 1 + val armed = ecgGateStep + handler.postDelayed({ + if (ecgGateReport == null || ecgGateStep != armed) return@postDelayed + send(CommandNumber.GET_DEVICE_CONFIG_VALUE, DeviceConfigWriteGate.readBackPayload()) + handler.postDelayed({ + if (ecgGateReport == null || ecgGateStep != armed) return@postDelayed + ecgGateReport?.noteReadBackTimeout((ECG_GATE_READ_BACK_TIMEOUT_MS / 1000).toInt()) + finishEcgGateWrite() + }, ECG_GATE_READ_BACK_TIMEOUT_MS) + }, ECG_GATE_SETTLE_MS) + } + + /** Publish + log the finished write/verify report and re-close the send() allowlist. */ + private fun finishEcgGateWrite() { + val report = ecgGateReport ?: return + ecgGateReport = null + _ecgRawDataGate.value = report + log("ECG gate (#891):\n${report.render()}") + } + + /** Clear the #891 result (Settings row dismissed / disconnect). Twin of Swift clearEcgRawDataGate(). */ + fun clearEcgRawDataGate() { _ecgRawDataGate.value = null } + + /** + * #891: the WRITE's own COMMAND_RESPONSE. Recorded for the transcript and deliberately NOT used to + * decide the verdict — the 121 read-back is what settles it. + */ + private fun handleEcgGateWriteAck(frame: ByteArray, cmdOff: Int) { + if (ecgGateReport == null) return + // The 5/MG result code is at cmdOff + 2 (cmd byte, then the 2-byte response header). + val resultIndex = cmdOff + 2 + val code = if (frame.size > resultIndex) (frame[resultIndex].toInt() and 0xFF) else null + ecgGateReport?.noteWriteAck(code) + _ecgRawDataGate.value = ecgGateReport + } + + /** + * #891: the read-back COMMAND_RESPONSE for 121. In-flight-guarded, and parsed by the same pure + * [DeviceConfigReadProbe.parse] the read probe uses — including its CRC gate. + */ + private fun handleEcgGateReadBack(frame: ByteArray, isWhoop5: Boolean) { + if (ecgGateReport == null) return + val family = if (isWhoop5) DeviceFamily.WHOOP5 else DeviceFamily.WHOOP4 + val parsed = DeviceConfigReadProbe.parse( + frame, family, DeviceConfigWriteGate.GET_DEVICE_CONFIG_VALUE_CMD, + ) + if (parsed.value != null) { + ecgGateReport?.noteReadBack(parsed.value) + } else if (parsed.failure != null) { + ecgGateReport?.noteReadBackFailure(parsed.failure) + } + finishEcgGateWrite() + } + /** * EXPERIMENTAL (#174): write the official app's `enable_r22_*` SET_CONFIG sequence to a bonded * WHOOP 5/MG to switch on the deep biometric (type-0x2F "R22") streams the strap withholds from a @@ -6827,6 +7091,12 @@ class WhoopBleClient( _deviceConfigProbe.value = null deviceConfigReport = null deviceConfigAwaiting = null + // #891: drop a stale write/verify result and abandon a verification the link interrupted, which + // re-closes the 119/121 send() allowlist. An unverified write must never be left showing a verdict + // it never reached. A disconnected strap has attested nothing, so the MG-only gate closes too. + _ecgRawDataGate.value = null + ecgGateReport = null + _whoop5Variant.value = Whoop5Variant.UNKNOWN reset() // close() can itself throw DeadObjectException on a dead binder — teardown must NEVER throw, diff --git a/android/app/src/main/java/com/noop/protocol/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..9f46527bbe 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,23 @@ package com.noop.protocol * * ## What this establishes, and the honest failure case * - * **Both target opcodes may simply not be implemented in firmware.** The repo's own protocol table - * (`whoop_protocol.json`, [CommandNumber]) names 121 `GET_DEVICE_CONFIG_VALUE` and 128 `GET_FF_VALUE`, - * but a name in a table is not a served verb: opcode 96 (`ENTER_HIGH_FREQ_HISTORICAL_MODE`) is a - * standing example of a number the table carries that nothing in the wild sends. So the probe's PRIMARY - * deliverable is a clean verdict per verb — **answered**, **rejected as UNSUPPORTED**, **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 - * 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**. + * 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. * - * 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 +83,17 @@ object DeviceConfigReadProbe { /** `SET_FF_VALUE` (120 / 0x78). Named ONLY so the allowlist can name what it excludes. */ const val SET_FEATURE_FLAG_VALUE_CMD = 120 - /** The complete set of opcodes this probe may put on the wire. The BLE send path admits these two - * and only these two while a probe is in flight; [isReadOnlyOpcode] is the predicate it asks. */ - val READ_ONLY_OPCODES = setOf(GET_DEVICE_CONFIG_VALUE_CMD, GET_FEATURE_FLAG_VALUE_CMD) + /** The complete set of opcodes this probe may put on the wire: the two VALUE reads above, plus the + * two DEVICE-CONFIG ENUMERATION verbs the probe now tries first ([ConfigKeySweep], 115/116 — the + * structural twins of the 117/118 pair #872 shipped and a real strap answered read-only). The BLE + * send path admits these four and only these four while a probe is in flight; [isReadOnlyOpcode] is + * the predicate it asks, and unit tests prove it rejects 119, 120 and every other opcode. */ + val READ_ONLY_OPCODES = setOf( + GET_DEVICE_CONFIG_VALUE_CMD, + GET_FEATURE_FLAG_VALUE_CMD, + ConfigKeySweep.START_DEVICE_CONFIG_KEY_EXCHANGE_CMD, + ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD, + ) /** The config WRITE verbs, which this probe must never emit. Kept as a named set so the read-only * contract is testable as a property of the allowlist rather than as a claim in a comment. */ @@ -97,37 +105,22 @@ 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, plus up to [ConfigKeySweep.MAX_ENUMERATED_VALUE_READS] value + * reads when enumeration produced a list, and — when the sweep runs — every candidate name asked + * through EVERY answering verb. + * + * Raised from 128 when that per-verb fan-out landed: 2 × the catalogue on top of the rest passes 128, + * and the cap would have truncated the walk. It still says "safety cap reached", but a short sweep + * presented as a finished one is exactly the failure this number exists to prevent. */ + const val MAX_STEPS = 320 /** The one device-config key NOOP already knows a real strap accepts: the Broadcast-HR flag written * via SET_DEVICE_CONFIG_VALUE and hardware-validated in #181. Used as the discovery key for opcode * 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 +156,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) @@ -203,6 +202,34 @@ object DeviceConfigReadProbe { return record[valueIndex].toInt() and 0xFF } + /** + * The value as the strap actually stores it — the WHOLE NUL-terminated ASCII string after the + * echoed name field, not just its first byte. + * + * Device-config values are **not** all single characters. A WHOOP 5 MG's own 115/116 enumeration + * listed `max_collection_backlog`, whose value reads `"0.0"` — three characters. [valueFor] would + * report that as `'0'` and quietly lose the rest, which is fine for a flag and wrong for anything + * else, so any caller comparing a value against what it asked for must use this. + * + * Stops at the first NUL, which is what keeps the puffin envelope's 4-byte-boundary padding out of + * the answer. Returns null — "no value claimed", never "empty" — when the key was not echoed, when + * nothing follows the name field, or when what follows is not printable ASCII. + * Keep in lockstep with the Swift `ValueResponse.stringValue(for:)`. + */ + fun stringValueFor(key: String): String? { + val off = echoOffset(key) ?: return null + val start = off + NAME_FIELD_BYTES + if (start >= record.size) return null + val out = StringBuilder() + for (i in start until record.size) { + val b = record[i].toInt() and 0xFF + if (b == 0) break + if (b < 0x20 || b > 0x7E) return null + out.append(b.toChar()) + } + return if (out.isEmpty()) null else out.toString() + } + // ByteArray fields need explicit equals/hashCode for a data class to compare by content. override fun equals(other: Any?): Boolean { if (this === other) return true @@ -256,35 +283,74 @@ 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, + /** Run the candidate sweep EVEN IF enumeration succeeded. Default false — see [runsCandidateSweep]. */ + val forceCandidateSweep: Boolean = false, ) { + /** + * Whether this run will ask the guessed names at all. + * + * By default the sweep is a FALLBACK: a strap that enumerated its own device-config keys has already + * answered the question guessing was for, so the sweep is skipped. + * + * That default is right for the device-config namespace and **incomplete as a general claim**, which + * is why [forceCandidateSweep] exists. Enumeration reports the keys the firmware holds; a key it would + * accept but has never been given a value for need not be among them, and the oracle cannot separate + * that case from "no such key" because both answer `FAILURE(0)`. A successful enumeration is therefore + * evidence about what the strap HAS, not proof of what it would ACCEPT — and it says nothing at all + * about the FEATURE-FLAG namespace, which is where most of the catalogue is aimed. + * + * So the forced run stays available, and stays explicit: it costs one round-trip per catalogue name, + * which is not something to spend by default. + */ + val runsCandidateSweep: Boolean get() = forceCandidateSweep || _enumeratedKeys.isEmpty() + /** Which part of the plan a step belongs to. Drives both the ordering and the report's sections. */ - enum class Group { DISCOVERY, KNOWN_FLAG, CANDIDATE } + enum class Group { ENUMERATE, DISCOVERY, CROSS_NAMESPACE, KNOWN_KEY, CANDIDATE } - /** One planned round-trip. */ - data class Step(val opcode: Int, val key: String, val group: Group) + /** One planned round-trip. [derivation] is set only for candidate steps. */ + data class Step( + val opcode: Int, + val key: String, + val group: Group, + val derivation: ConfigKeySweep.Derivation? = null, + ) - /** What one verb has been shown to do. `UNTRIED` until its discovery step resolves. */ + /** What one verb has been shown to do. `UNTRIED` until its first step resolves. */ enum class VerbStatus(val label: String) { UNTRIED("untried"), + /** A decodable COMMAND_RESPONSE came back and was not an explicit UNSUPPORTED. */ ANSWERED("answered"), + /** The firmware refused the opcode (5/MG result code 3). */ UNSUPPORTED("unsupported"), + /** No reply inside the probe's per-step window. */ SILENT("silent"), + /** A reply arrived but could not be decoded (CRC, envelope, or a short record). */ UNDECODABLE("undecodable"), } @@ -298,7 +364,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 +378,42 @@ class DeviceConfigReadProbeReport( var deviceConfigVerb: VerbStatus = VerbStatus.UNTRIED private set + /** Status of the device-config ENUMERATION pair (115/116), taken as one verb: 116 cannot be asked + * without 115 having answered, so a single verdict describes the pair. */ + var enumerationVerb: VerbStatus = VerbStatus.UNTRIED + private set + + private val _enumeratedKeys = mutableListOf() + + /** Device-config key names the strap listed for itself. The headline result when it is non-empty. */ + val enumeratedKeys: List get() = _enumeratedKeys + + /** The key count `START_DEVICE_CONFIG_KEY_EXCHANGE` announced, when it answered. */ + var enumeratedCount: Int? = null + private set + + /** Entries the strap called real keys whose NAME did not decode, stepped over rather than trusted as a + * terminator (the discipline #874 established for the 117/118 walk). */ + var enumerationSkipped: Int = 0 + private set + + /** `GET_FF_VALUE(128)` asked for the known DEVICE-CONFIG key: does the flag verb see that namespace? */ + var featureFlagVerbOnDeviceConfigKey: ConfigKeySweep.Existence? = null + private set + + /** `GET_DEVICE_CONFIG_VALUE(121)` asked for a known FLAG key: does that verb see the other namespace? */ + var deviceConfigVerbOnFlagKey: ConfigKeySweep.Existence? = null + private set + private val _readings = mutableListOf() + /** Every reading, in the order the strap served it. */ val readings: List get() = _readings private val _trace = mutableListOf() - /** Trace lines: one per round-trip plus any failure notes. */ + + /** Trace lines. Candidate round-trips are summarised in their own section rather than repeated here, + * EXCEPT the ones that are not a plain `unknown` — a hit or an odd reply always appears in full. */ val trace: List get() = _trace /** Round-trips attempted. Bounds the walk against [DeviceConfigReadProbe.MAX_STEPS]. */ @@ -324,15 +424,12 @@ class DeviceConfigReadProbeReport( var stopReason: String? = null private set - /** - * Per-verb reply window, in seconds, recorded by [noteTimeout]. The verdict names the window a verb - * went silent through instead of converting that silence into a claim about the firmware. - */ - private val silenceWindow = mutableMapOf() - - private var phase = 0 // 0 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 +441,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 +464,29 @@ class DeviceConfigReadProbeReport( return null } - /** One candidate step from the current phase, or null when that phase is exhausted. */ + /** One step from the current phase, or null when that phase is exhausted. */ private fun stepInCurrentPhase(): Step? = when (phase) { - 0 -> { - // Discovery: one round-trip per verb, each against a key that verb has a reason to know. - // 128 gets a flag NOOP writes; 121 gets the Broadcast-HR key, hardware-validated in #181. + 0 -> when (enumPhase) { + // Ask the strap to list its own device-config keys. 115 once; then 116 until the strap says + // stop, exactly as the 117/118 walk does. + 0 -> Step(ConfigKeySweep.START_DEVICE_CONFIG_KEY_EXCHANGE_CMD, "", Group.ENUMERATE) + 1 -> if (enumSteps >= ConfigKeySweep.MAX_ENUMERATION_STEPS) { + if (stopReason == null) { + stopReason = "device-config enumeration hit its cap of " + + "${ConfigKeySweep.MAX_ENUMERATION_STEPS} entries; the rest of the plan still ran" + } + enumPhase = 2 + null + } else { + enumSteps += 1 + Step(ConfigKeySweep.SEND_NEXT_DEVICE_CONFIG_CMD, "", Group.ENUMERATE) + } + else -> null + } + 1 -> { + // Discovery: one round-trip per VALUE verb, each against a key that verb has a reason to know. + // 128 gets a flag NOOP writes; 121 gets the Broadcast-HR key NOOP has written since #181, so a + // FAILURE there is evidence about the VERB, not about the key. val plan = listOf( Step( DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD, @@ -382,53 +501,197 @@ 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 — + // unless this run was explicitly asked to sweep anyway (see [runsCandidateSweep] for why a + // successful enumeration does not close the question). + // + // EVERY candidate goes through EVERY answering verb, not through the one its `namespace` + // field guesses. That field is an author's expectation, and the namespaces are now PROVEN + // SEPARATE on hardware: 128 asked for a device-config key answers FAILURE, and 121 asked for a + // feature-flag key answers FAILURE. So a candidate that really is a device-config key, asked + // only through 128, comes back FAILURE and is indistinguishable from "no such key" — which + // would have made a negative sweep worthless for exactly the names it most needed to settle. + // Asking both costs one extra round-trip per name and is what lets a negative be called clean. + val verbs = candidateVerbs() + if (!runsCandidateSweep || verbs.isEmpty()) { null + } else { + val idx = cursor / verbs.size + if (idx >= batch.candidates.size) { + null + } else { + val candidate = batch.candidates[idx] + Step(verbs[cursor % verbs.size], 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 VALUE verbs a candidate name is asked through — every one that answered, in a stable order + * (121 before 128) so the plan is deterministic. Empty when neither answered, which retires the sweep. + */ + private fun candidateVerbs(): List { + val verbs = mutableListOf() + if (deviceConfigVerb == VerbStatus.ANSWERED) { + verbs.add(DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD) + } + if (featureFlagVerb == VerbStatus.ANSWERED) { + verbs.add(DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD) + } + return verbs } - /** The 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 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 } - /** Record one decoded reply. */ + /** + * 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 the `START_DEVICE_CONFIG_KEY_EXCHANGE` reply. An implausible count is reported but never + * trusted as a loop bound — the walk is bounded by [ConfigKeySweep.MAX_ENUMERATION_STEPS] and by the + * strap's own end marker. + */ + fun noteEnumerationStart(r: FeatureFlagProbe.StartResponse) { + enumeratedCount = r.count + if (r.resultCode == 3) { + enumerationVerb = VerbStatus.UNSUPPORTED + enumPhase = 2 + _trace.add( + "START_DEVICE_CONFIG_KEY_EXCHANGE(115) → result=UNSUPPORTED(3) — " + + "the firmware does not serve this verb", + ) + return + } + enumerationVerb = VerbStatus.ANSWERED + enumPhase = 1 + var line = "START_DEVICE_CONFIG_KEY_EXCHANGE(115) →" + val code = r.resultCode + if (code != null) line += " result=${FeatureFlagProbe.resultLabel(code)}($code)" + line += " revision=${r.revision} count=${r.count}" + if (!r.countIsPlausible) line += " (implausible — walked to the strap's own end marker instead)" + _trace.add(line) + } + + /** + * Record one `SEND_NEXT_DEVICE_CONFIG` reply. Returns true when the walk should continue. + * + * Mirrors the #874 discipline on the 117/118 walk: the strap's own end marker terminates the walk, but + * a name OUR parser declines ([FeatureFlagProbe.NextResponse.isSkippable]) is counted and stepped over + * — one undecodable entry must not throw away every key after it. + */ + fun noteEnumerationNext(r: FeatureFlagProbe.NextResponse): Boolean { + if (r.isExhausted) { + enumPhase = 2 + _trace.add("SEND_NEXT_DEVICE_CONFIG(116) → end of list (index=${r.index} validKey=${r.validKey})") + return false + } + if (r.isSkippable) { + enumerationSkipped += 1 + _trace.add("SEND_NEXT_DEVICE_CONFIG(116) → index=${r.index} name did not decode — stepped over") + return true + } + val key = r.key + if (key != null) { + _enumeratedKeys.add(key) + _trace.add("SEND_NEXT_DEVICE_CONFIG(116) → index=${r.index} key=\"$key\"") + } + return true + } + + /** Record one decoded VALUE reply. */ fun noteReply(r: DeviceConfigReadProbe.ValueResponse, step: Step) { setStatus(if (r.isUnsupported) VerbStatus.UNSUPPORTED else VerbStatus.ANSWERED, step.opcode) + if (step.group == Group.CROSS_NAMESPACE) { + if (step.opcode == DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD) { + featureFlagVerbOnDeviceConfigKey = r.existence + } else if (step.opcode == DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD) { + deviceConfigVerbOnFlagKey = r.existence + } + } val value = r.valueFor(step.key) _readings.add( - Reading(step.group, step.opcode, step.key, value, r.resultCode, r.recordHex), + Reading(step.group, step.opcode, step.key, value, r.resultCode, r.recordHex, step.derivation), ) + // The candidate section lists every name it asked, so repeating a plain "unknown" in the + // transcript would double the report for no information. Anything else is always traced. + if (step.group == Group.CANDIDATE && r.existence == ConfigKeySweep.Existence.UNKNOWN) return var line = "${opcodeLabel(step.opcode)} key=\"${step.key}\"" val code = r.resultCode line += if (code != null) " → result=${FeatureFlagProbe.resultLabel(code)}($code)" else " →" + line += " ${r.existence.label}" if (value != null) line += " value=${DeviceConfigReadProbe.valueLabel(value)}" line += " record=[${r.recordHex}]" _trace.add(line) @@ -450,13 +713,8 @@ class DeviceConfigReadProbeReport( /** * Record the strap answering nothing at all within the per-step window. The verb is marked silent, * which retires it — one no-reply must not cost another twenty timeouts. - * - * The window is kept, not just printed, because the verdict has to be able to say how long nothing - * came back for. Silence is not the firmware refusing; it does not even establish that a frame was - * transmitted (see the header), so it can never be reported as what the firmware serves. */ fun noteTimeout(step: Step, seconds: Int) { - silenceWindow[step.opcode] = seconds setStatus(VerbStatus.SILENT, step.opcode) _trace.add("${opcodeLabel(step.opcode)} key=\"${step.key}\" → no COMMAND_RESPONSE within ${seconds}s") } @@ -466,111 +724,272 @@ 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)" - } - - /** - * 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. - */ - private fun outcome(status: VerbStatus, opcode: Int): String = when (status) { - VerbStatus.UNTRIED -> "not asked" - VerbStatus.ANSWERED -> "answered" - VerbStatus.UNSUPPORTED -> "refused by firmware (UNSUPPORTED)" - VerbStatus.SILENT -> silenceWindow[opcode] - ?.let { "served no reply in ${it}s — unconfirmed" } - ?: "served no reply — unconfirmed" - VerbStatus.UNDECODABLE -> "replied but the frame did not decode — unconfirmed" + 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 } + /** - * 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. + * Every key name this run proved EXISTS that NOOP did not already have — the whole point of the + * exercise. Enumerated names count; so does any candidate the oracle confirmed. */ + val newKeysFound: List + get() { + val out = _enumeratedKeys.filter { + !knownFlagKeys.contains(it) && it != DeviceConfigReadProbe.DEVICE_CONFIG_DISCOVERY_KEY + }.toMutableList() + for (r in candidateReadings) { + if (r.existence == ConfigKeySweep.Existence.EXISTS && !out.contains(r.key)) out.add(r.key) + } + return out + } + + /** One-line summary of what the probe established. */ val verdict: String get() { + val found = newKeysFound + if (found.isNotEmpty()) { + return "${found.size} config key name(s) found that NOOP did not have: ${found.joinToString(", ")}" + } + // Only claim "enumeration settled it" when enumeration was in fact the whole run. A forced + // sweep asked dozens of names as well, and its clean negative is the more informative headline. + if (enumerationVerb == VerbStatus.ANSWERED && candidateReadings.isEmpty()) { + return "the strap enumerated its device-config namespace and returned no key NOOP did not already have" + } val answered = listOf(featureFlagVerb, deviceConfigVerb).count { it == VerbStatus.ANSWERED } if (answered == 0) { - if (featureFlagVerb == VerbStatus.UNSUPPORTED && deviceConfigVerb == VerbStatus.UNSUPPORTED) { - return "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this " + - "firmware — rejected as UNSUPPORTED" + val both = + "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" + if (featureFlagVerb == VerbStatus.UNSUPPORTED || deviceConfigVerb == VerbStatus.UNSUPPORTED) { + return "$both — rejected as UNSUPPORTED" } - 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" + if (featureFlagVerb == VerbStatus.SILENT && deviceConfigVerb == VerbStatus.SILENT) { + return "$both — no reply to either" + } + return both + } + // Counted in NAMES, not round-trips: each name is asked through every answering verb, so the + // headline would otherwise double. A name only counts as "does not exist" when EVERY verb that + // asked it said so. + val names = candidateReadings.map { it.key }.distinct() + val asked = names.size + if (asked == 0) { + return "$answered of 2 read verbs answered; no candidate name was asked" } - val 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 unknown = names.count { key -> + val rows = candidateReadings.filter { it.key == key } + rows.isNotEmpty() && rows.all { it.existence == ConfigKeySweep.Existence.UNKNOWN } } - return "$answered of 2 read verbs answered; read $named config value(s)" + if (unknown == asked) { + // A fully-negative sweep is worth more when enumeration ALSO answered: the device-config + // namespace is then fully listed and the guessed names are all refused, which is a much + // stronger negative than a sweep run against a strap that never listed anything. + return if (enumerationVerb == VerbStatus.ANSWERED) { + "asked $asked candidate key name(s); this firmware has none of them, and its device-config namespace enumerated in full (a clean negative)" + } else { + "asked $asked candidate key name(s); this firmware has none of them (a clean negative)" + } + } + return "asked $asked candidate key name(s); $unknown do not exist, ${asked - unknown} inconclusive" } /** The full copyable report (byte-identical to the Swift `render()`). */ fun render(): String { val fam = if (family == DeviceFamily.WHOOP5) "WHOOP 5/MG" else "WHOOP 4.0" val sb = StringBuilder() - sb.append("#103 DEVICE-CONFIG READ PROBE — $fam\n") - sb.append("Read-only: GET_DEVICE_CONFIG_VALUE(121) + GET_FF_VALUE(128). No value is written; ") - sb.append("SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path.\n") - sb.append("Follow-up to the #761 enumeration probe: that one asked for key NAMES, this asks for VALUES.\n") + sb.append("#103 CONFIG KEY PROBE — $fam\n") + sb.append("Read-only: START_DEVICE_CONFIG_KEY_EXCHANGE(115), SEND_NEXT_DEVICE_CONFIG(116), ") + sb.append("GET_DEVICE_CONFIG_VALUE(121), GET_FF_VALUE(128).\n") + sb.append( + "No value is written; SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path.\n", + ) + sb.append( + "Oracle: result=SUCCESS(1) means the key NAME exists; result=FAILURE(0) means the firmware has no such key.\n", + ) sb.append("\nVerdict: $verdict\n") stopReason?.let { sb.append("Stopped: $it\n") } - sb.append("\nRead verbs:\n") - sb.append(" ").append(DeviceConfigReadProbe.padded("GET_FF_VALUE(128)", 30)) + sb.append("\nVerbs:\n") + sb.append(" ").append(DeviceConfigReadProbe.padded("device-config enumerate(115/116)", 34)) + .append(enumerationVerb.label).append("\n") + sb.append(" ").append(DeviceConfigReadProbe.padded("GET_FF_VALUE(128)", 34)) .append(featureFlagVerb.label).append("\n") - sb.append(" ").append(DeviceConfigReadProbe.padded("GET_DEVICE_CONFIG_VALUE(121)", 30)) + sb.append(" ").append(DeviceConfigReadProbe.padded("GET_DEVICE_CONFIG_VALUE(121)", 34)) .append(deviceConfigVerb.label).append("\n") + sb.append(enumerationSection()) + sb.append(namespaceSection()) sb.append( section( Group.DISCOVERY, - "Discovery — one round-trip per verb against a key it should know", + "Discovery — one round-trip per value verb against a key it should know", "(none — no reply was decoded)", ), ) sb.append( section( - Group.KNOWN_FLAG, - "Known feature-flag values (names NOOP already writes; values never read before)", - "(none — the verb that would carry them did not answer)", - ), - ) - sb.append( - section( - Group.CANDIDATE, - "Candidate oxygen keys — GUESSES, never observed on a wire or in any table", - "(none — the verb that would carry them did not answer)", + Group.KNOWN_KEY, + "Known key values (the flags NOOP writes, plus anything enumeration returned)", + "(none — no value verb answered)", ), ) + sb.append(candidateSection()) sb.append("\nExchange:\n") for (line in _trace) sb.append(" ").append(line).append("\n") return sb.toString() } + /** The strap's own device-config key list — the result that makes guessing unnecessary. */ + private fun enumerationSection(): String { + val sb = StringBuilder() + sb.append("\nDevice-config keys the strap listed for itself (115/116) (${_enumeratedKeys.size}):\n") + if (_enumeratedKeys.isEmpty()) { + sb.append( + when (enumerationVerb) { + VerbStatus.UNSUPPORTED -> " (none — the firmware refused 115 as UNSUPPORTED)\n" + VerbStatus.SILENT -> " (none — no reply to 115)\n" + VerbStatus.UNDECODABLE -> " (none — the reply did not decode)\n" + VerbStatus.ANSWERED -> " (none — 115 answered but the walk produced no names)\n" + VerbStatus.UNTRIED -> " (none — not reached)\n" + }, + ) + return sb.toString() + } + _enumeratedKeys.forEachIndexed { i, key -> + sb.append(" %2d. ".format(i + 1)).append(key).append("\n") + } + if (enumerationSkipped > 0) { + sb.append( + " ($enumerationSkipped further entr(ies) the strap called real but whose name did not decode)\n", + ) + } + val announced = enumeratedCount + if (announced != null && announced != _enumeratedKeys.size + enumerationSkipped) { + sb.append( + " (the strap announced $announced; the walk served ${_enumeratedKeys.size + enumerationSkipped})\n", + ) + } + return sb.toString() + } + + /** Whether the two namespaces are really separate — two round-trips that shape every future sweep. */ + private fun namespaceSection(): String { + val sb = StringBuilder() + sb.append("\nNamespace separation:\n") + val ffLabel = featureFlagVerbOnDeviceConfigKey?.label ?: "not asked" + val dcLabel = deviceConfigVerbOnFlagKey?.label ?: "not asked" + sb.append(" ").append(DeviceConfigReadProbe.padded("128 asked for a device-config key", 38)) + .append(ffLabel).append("\n") + sb.append(" ").append(DeviceConfigReadProbe.padded("121 asked for a feature-flag key", 38)) + .append(dcLabel).append("\n") + sb.append( + when { + featureFlagVerbOnDeviceConfigKey == ConfigKeySweep.Existence.EXISTS -> + " ⇒ GET_FF_VALUE(128) serves BOTH namespaces.\n" + deviceConfigVerbOnFlagKey == ConfigKeySweep.Existence.EXISTS -> + " ⇒ GET_DEVICE_CONFIG_VALUE(121) serves BOTH namespaces.\n" + featureFlagVerbOnDeviceConfigKey == ConfigKeySweep.Existence.UNKNOWN && + deviceConfigVerbOnFlagKey == ConfigKeySweep.Existence.UNKNOWN -> + " ⇒ the namespaces are separate: neither verb sees the other's keys.\n" + else -> " ⇒ inconclusive.\n" + }, + ) + return sb.toString() + } + + /** The candidate sweep, grouped by derivation, with the tested/untested arithmetic spelled out. */ + private fun candidateSection(): String { + val rows = candidateReadings + // NAMES, not round-trips: each name is now asked through every answering verb, so counting rows + // would report "108 asked of 54 in the catalogue". The arithmetic has to stay in the same units + // the catalogue is measured in or the untested figure goes negative and stops meaning anything. + val seen = rows.map { it.key }.distinct() + val tested = seen.size + val total = ConfigKeySweep.CATALOGUE.size + val untested = total - batch.start - tested + val sb = StringBuilder() + sb.append("\nCandidate key names — GUESSES, never observed on a wire or in any table") + if (forceCandidateSweep) sb.append(" [FULL SWEEP: asked even though enumeration succeeded]") + sb.append(" ($tested asked of $total in the catalogue") + sb.append(if (untested > 0) "; $untested untested" else "; none untested") + sb.append("):\n") + if (rows.isEmpty()) { + sb.append( + when { + _enumeratedKeys.isNotEmpty() && !forceCandidateSweep -> + " (skipped — the strap enumerated its own device-config keys, so nothing needs guessing.\n" + + " Enumeration lists what the firmware HOLDS, not everything it would ACCEPT, and says\n" + + " nothing about the feature-flag namespace: re-run with the full name sweep to ask anyway.)\n" + featureFlagVerb != VerbStatus.ANSWERED && deviceConfigVerb != VerbStatus.ANSWERED -> + " (none — no value verb answered, so no name could be asked)\n" + else -> " (none asked)\n" + }, + ) + return sb.toString() + } + // A NAME exists if ANY verb said so, and is only "does not exist" when EVERY verb that asked said + // so — the whole reason both verbs are asked. + val exists = seen.count { key -> + rows.any { it.key == key && it.existence == ConfigKeySweep.Existence.EXISTS } + } + val unknown = seen.count { key -> + val asked = rows.filter { it.key == key } + asked.isNotEmpty() && asked.all { it.existence == ConfigKeySweep.Existence.UNKNOWN } + } + sb.append(" $exists exist · $unknown do not · ${tested - exists - unknown} inconclusive") + sb.append(" (each name asked through ${candidateVerbs().size} verb(s))\n") + for (derivation in ConfigKeySweep.Derivation.entries) { + val names = seen.filter { key -> rows.any { it.key == key && it.derivation == derivation } } + if (names.isEmpty()) continue + sb.append("\n ${derivation.title} (${names.size}):\n") + names.forEachIndexed { i, key -> + sb.append(" %2d. ".format(i + 1)).append(DeviceConfigReadProbe.padded(key, 32)) + // Per-verb, so a name that answered differently on 121 and 128 is visible rather than + // collapsed into one word. + sb.append( + rows.filter { it.key == key }.joinToString(" · ") { r -> + val v = r.value + "${r.opcode}=${r.existence.label}" + + if (v != null) "(${DeviceConfigReadProbe.valueLabel(v)})" else "" + }, + ) + sb.append("\n") + } + } + 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/DeviceConfigWriteGate.kt b/android/app/src/main/java/com/noop/protocol/DeviceConfigWriteGate.kt new file mode 100644 index 0000000000..8b4451d426 --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/DeviceConfigWriteGate.kt @@ -0,0 +1,379 @@ +package com.noop.protocol + +/** + * #891 / #103: the ONE device-config key this app may write beyond the Broadcast-HR flag — + * `enable_raw_data_w_ecg` — and the key-aware allowlist that keeps every other key off the wire. + * + * ## Where the key came from + * + * The strap listed it itself. `START_DEVICE_CONFIG_KEY_EXCHANGE(115)` + `SEND_NEXT_DEVICE_CONFIG(116)` — + * the read-only enumeration pair [ConfigKeySweep] builds — was answered by a WHOOP 5 MG with + * `revision=1 count=7`, and the walk ran to a clean `index=255 validKey=false` terminator listing: + * + * ``` + * sigproc_wear_detect enable_rfid max_collection_backlog + * cont_collection_mode whoop_live_hr_in_adv_ind_pkt whoop_live_2_hrm_devices + * enable_raw_data_w_ecg + * ``` + * + * Six of those seven were unknown to this codebase. Nothing here is guessed: the names are the strap's + * own, read off its own enumeration. + * + * ## Why this key, and why now + * + * #891 records that all three TOGGLE_LABRADOR (ECG) commands — 139, 125, 124 — answer `SUCCESS(1)` on a + * WHOOP 5 MG and produce **zero** ECG packets in a 30-second listen. What the echoed byte those replies + * carry actually MEANS is still open: arg-echo is refuted (SELECT_WRIST was sent 0 and answered 1) and + * blanket payload-echo is refuted (GET_BATTERY_LEVEL sends `[0x00]` and answers 0x2F), but "read-back of + * stored state" remains an inference — a per-opcode handler echoing a constant fits every observation so + * far just as well. Either way the practical lesson holds: a `SUCCESS` result byte is not evidence that + * state changed. + * + * On the same strap `enable_raw_data_w_ecg` reads `'0'`. A device-config key whose name pairs "raw data" + * with "ecg", sitting at `'0'` on a strap whose ECG toggles accept and emit nothing, is the leading + * candidate for the gate. **Whether flipping it actually produces ECG data is UNKNOWN.** A negative result + * — gate flipped to `'1'`, read back as `'1'`, still no packets — is a publishable answer that removes the + * leading hypothesis from #891, and is the outcome this path is built to establish either way. + * + * ## Why a write is safe to offer at all + * + * Config writes on this firmware are demonstrated, not assumed: running the existing `enable_r22_*` + * sequence (#174) moved `enable_sig12` from `'2'` (0x32) to `'1'` (0x31), confirmed by a 121 read before + * and after. So a `SET_DEVICE_CONFIG_VALUE(119)` write lands, and the value that comes back afterwards is + * real rather than an echo of what was sent. + * + * ## Read-back is the proof, not the ack + * + * The write's own `COMMAND_RESPONSE` is **not** treated as evidence. #891 is the standing example of why: + * `SELECT_WRIST` returns SUCCESS for a no-op and FAILURE for a real change, so a result byte says nothing + * reliable about whether state moved. Every write from this path is therefore followed by a + * `GET_DEVICE_CONFIG_VALUE(121)` read of the same key, and only the value that comes back is reported. + * (Framing owed to @ryanbr on #891.) + * + * ## The allowlist is key-aware, not just opcode-aware + * + * Opcode 119 is shared: the Broadcast-HR flag (#181) writes through it too. An opcode-only allowlist + * therefore cannot express "this key and no other", and before this file the 5/MG send path admitted ANY + * device-config key while the Broadcast-HR opt-in happened to be on. [admitsSend] closes that: it parses + * the key name out of the body and admits exactly two keys, each only while its OWN opt-in is on. The five + * remaining enumerated keys are named in [OUT_OF_SCOPE_KEYS] and are refused unconditionally — their + * effects are unknown and nothing here has any reason to move them. + * + * This is the same discipline [DeviceConfigReadProbe.isReadOnlyOpcode] established for the read probes: a + * single pure predicate that the BLE send path itself consults, so a unit test proving the predicate + * rejects something is proving it about the real wire path and not about a parallel copy of the rule. + * + * Pure: no Android BLE, no I/O, no preferences. The caller supplies the two opt-in booleans. Twin of the + * Swift `DeviceConfigWriteGate` — keep them byte-identical. + */ +object DeviceConfigWriteGate { + + // Opcodes --------------------------------------------------------------------------------------- + + /** `SET_DEVICE_CONFIG_VALUE` (119 / 0x77) — the only write verb this gate ever admits. */ + const val SET_DEVICE_CONFIG_VALUE_CMD = 119 + + /** `SET_FF_VALUE` (120 / 0x78) — the FEATURE-FLAG write verb (the R22 sequence, #174). Named here for + * exactly one reason: so [admitsSend] can be proved to refuse it. */ + const val SET_FF_VALUE_CMD = 120 + + /** `GET_DEVICE_CONFIG_VALUE` (121 / 0x79) — the read verb the mandatory read-back uses. */ + const val GET_DEVICE_CONFIG_VALUE_CMD = 121 + + // Keys ------------------------------------------------------------------------------------------ + + /** The key this file exists for. Written ONLY while the ECG-gate opt-in is on AND the strap has + * positively attested itself a WHOOP MG ([Whoop5Variant.isMG]) — a plain 5.0 has no electrodes. */ + const val ECG_RAW_DATA_KEY = "enable_raw_data_w_ecg" + + /** The Broadcast-HR key (#181), hardware-validated. Admitted only while ITS own opt-in is on. */ + const val BROADCAST_HR_KEY = "whoop_live_hr_in_adv_ind_pkt" + + /** The other five keys the strap's 115/116 enumeration listed. Each refused unconditionally: their + * effects are undocumented and unmeasured, and `max_collection_backlog` in particular reads `"0.0"`, + * which is not even a flag. Listed rather than merely omitted so the refusal is testable. */ + val OUT_OF_SCOPE_KEYS: List = listOf( + "sigproc_wear_detect", + "enable_rfid", + "max_collection_backlog", + "cont_collection_mode", + "whoop_live_2_hrm_devices", + ) + + /** Every device-config key the strap enumerated, in the order it served them. Reported in the UI and + * used by tests; never used to build a write. */ + val ENUMERATED_KEYS: List = listOf( + "sigproc_wear_detect", + "enable_rfid", + "max_collection_backlog", + "cont_collection_mode", + BROADCAST_HR_KEY, + "whoop_live_2_hrm_devices", + ECG_RAW_DATA_KEY, + ) + + // Values ---------------------------------------------------------------------------------------- + + /** ASCII `'1'` — the gate on. */ + const val ENABLED_VALUE = 0x31 + + /** ASCII `'0'` — the gate off, and what a subscription-free MG reads today. */ + const val DISABLED_VALUE = 0x30 + + /** The value byte for a requested state. */ + fun value(on: Boolean): Int = if (on) ENABLED_VALUE else DISABLED_VALUE + + /** The value byte rendered as the character the strap stores. */ + fun valueString(on: Boolean): String = if (on) "1" else "0" + + /** Width of the key-name field in a device-config body. */ + const val NAME_FIELD_BYTES = 32 + + // Body parsing ---------------------------------------------------------------------------------- + + /** + * The key name carried by a `SET_DEVICE_CONFIG_VALUE` payload, or null when the payload is not shaped + * like one. + * + * The payload the send path holds is `[0x01] + deviceConfigBody(...)`: the b3 byte, then the 32-byte + * NUL-padded name, then the value. A name is only returned when it is printable ASCII and the + * remainder of the field is genuine NUL padding — so a body that is short, mis-shaped, or carrying + * binary in the name field yields null and is refused rather than guessed at. + */ + fun keyNameInSendPayload(payload: ByteArray): String? { + if (payload.size < 1 + NAME_FIELD_BYTES) return null + if ((payload[0].toInt() and 0xFF) != 0x01) return null + val name = StringBuilder() + var terminated = false + for (i in 0 until NAME_FIELD_BYTES) { + val b = payload[i + 1].toInt() and 0xFF + if (b == 0) { terminated = true; break } + if (b < 0x20 || b > 0x7E) return null + name.append(b.toChar()) + } + if (name.isEmpty()) return null + // Everything after the name must be NUL, or this is not a NUL-padded name field. + if (terminated) { + for (i in name.length until NAME_FIELD_BYTES) { + if ((payload[i + 1].toInt() and 0xFF) != 0) return null + } + } + return name.toString() + } + + // The allowlist predicate ----------------------------------------------------------------------- + + /** + * Whether a device-config KEY may be written, given the two opt-ins and the hardware attestation. + * + * `false` for every key that is not one of the two named ones — including all five of + * [OUT_OF_SCOPE_KEYS], and including any key a future edit invents. The ECG key additionally requires + * [isMG]: `Whoop5Variant.UNKNOWN` is not MG, so an unattested strap fails this the same way a plain + * 5.0 does. + */ + fun isWritableKey( + key: String, + ecgGateOptIn: Boolean, + isMG: Boolean, + broadcastHrOptIn: Boolean, + ): Boolean = when (key) { + ECG_RAW_DATA_KEY -> ecgGateOptIn && isMG + BROADCAST_HR_KEY -> broadcastHrOptIn + else -> false + } + + /** + * **The send allowlist itself.** True only for `SET_DEVICE_CONFIG_VALUE(119)` carrying a well-formed + * body whose key passes [isWritableKey]. + * + * Every other opcode is false — explicitly including `SET_FF_VALUE(120)`, which the R22 sequence keeps + * its own separate clause for and which must never be reachable from here. + */ + fun admitsSend( + opcode: Int, + payload: ByteArray, + ecgGateOptIn: Boolean, + isMG: Boolean, + broadcastHrOptIn: Boolean, + ): Boolean { + if (opcode != SET_DEVICE_CONFIG_VALUE_CMD) return false + val key = keyNameInSendPayload(payload) ?: return false + return isWritableKey(key, ecgGateOptIn, isMG, broadcastHrOptIn) + } + + /** The read verb the post-write verification is allowed to send, and only that one. */ + fun isReadBackOpcode(opcode: Int): Boolean = opcode == GET_DEVICE_CONFIG_VALUE_CMD + + // Frames ---------------------------------------------------------------------------------------- + + /** + * The `SET_DEVICE_CONFIG_VALUE(119)` payload that sets the ECG gate. + * + * Deliberately the SAME 33-byte single-value body the Broadcast-HR write has used on real hardware + * since #181 — `[0x01] + [name NUL-padded to 32][value]`. The strap serves multi-character values + * (`max_collection_backlog` reads `"0.0"`), so the READ side must handle them; but this key's observed + * value is a single ASCII digit and a one-character write is the shape hardware has already accepted. + */ + fun writePayload(on: Boolean): ByteArray = + byteArrayOf(0x01) + Whoop5Config.deviceConfigBody(ECG_RAW_DATA_KEY, value(on)) + + /** The `GET_DEVICE_CONFIG_VALUE(121)` payload that reads the ECG gate back. */ + fun readBackPayload(): ByteArray = DeviceConfigReadProbe.requestBody(ECG_RAW_DATA_KEY) +} + +/** + * The result of one ECG-gate write + mandatory read-back, as a copyable report. + * + * Order-dependent and pure (noteWriteAck → noteReadBack/noteReadBackTimeout → render), so the unit tests + * cover the whole verdict table without a strap. Twin of the Swift `EcgRawDataGateReport`; [render] is + * byte-identical across platforms so a shared strap log reads the same either side. + */ +class EcgRawDataGateReport(on: Boolean) { + + /** What the run established. Deliberately blunt about the case that matters: a write whose ack said + * SUCCESS but whose read-back did not move is [UNCHANGED], not success. */ + enum class Verdict(val label: String) { + /** Read-back returned exactly the value that was requested. The only success case. */ + CONFIRMED("confirmed"), + + /** Read-back returned a DIFFERENT value than requested — the write did not take. */ + UNCHANGED("unchanged"), + + /** The strap answered the read-back but did not echo the key, so no value can be claimed. */ + NOT_CLAIMED("notClaimed"), + + /** The strap refused the read-back verb, or answered FAILURE for the key. */ + REFUSED("refused"), + + /** No reply to the read-back inside its window. */ + SILENT("silent"), + + /** A reply arrived that could not be decoded (CRC, envelope, short record). */ + UNDECODABLE("undecodable"), + + /** The read-back has not resolved yet. */ + PENDING("pending"), + } + + /** The value that was requested, as the strap stores it ("1" or "0"). */ + val requested: String = DeviceConfigWriteGate.valueString(on) + + /** Result code of the WRITE's own COMMAND_RESPONSE, when one arrived. Recorded, never trusted. */ + var writeResultCode: Int? = null + private set + + /** The value the read-back actually returned. null means "no value claimed", never "zero". */ + var storedValue: String? = null + private set + + /** Result code of the READ-BACK's COMMAND_RESPONSE. */ + var readBackResultCode: Int? = null + private set + + /** Raw read-back record bytes as hex, always reported whatever else decodes. */ + var readBackRecordHex: String? = null + private set + + private val _trace = mutableListOf() + + /** Trace lines, one per round-trip. */ + val trace: List get() = _trace + + /** The verdict so far. */ + var verdict: Verdict = Verdict.PENDING + private set + + init { + _trace.add( + "SET_DEVICE_CONFIG_VALUE(119) key=\"${DeviceConfigWriteGate.ECG_RAW_DATA_KEY}\" " + + "value='$requested' sent", + ) + } + + /** Record the write's own ack. It is logged and NOT used to decide anything: #891 established that a + * SUCCESS result code does not prove state changed. */ + fun noteWriteAck(resultCode: Int?) { + writeResultCode = resultCode + val label = resultCode?.let { "${FeatureFlagProbe.resultLabel(it)}($it)" } ?: "(unlabelled)" + _trace.add( + "write ack → result=$label — recorded, not treated as proof; the read-back below is the proof", + ) + } + + /** Record the decoded read-back and reach a verdict. */ + fun noteReadBack(r: DeviceConfigReadProbe.ValueResponse) { + readBackResultCode = r.resultCode + readBackRecordHex = r.recordHex + val stored = r.stringValueFor(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + storedValue = stored + val sb = StringBuilder( + "GET_DEVICE_CONFIG_VALUE(121) key=\"${DeviceConfigWriteGate.ECG_RAW_DATA_KEY}\"", + ) + val c = r.resultCode + if (c != null) sb.append(" → result=${FeatureFlagProbe.resultLabel(c)}($c)") else sb.append(" →") + if (stored != null) sb.append(" value='$stored'") + sb.append(" record=[${r.recordHex}]") + _trace.add(sb.toString()) + + verdict = when { + r.isUnsupported || r.isFailure -> Verdict.REFUSED + stored == null -> Verdict.NOT_CLAIMED + stored == requested -> Verdict.CONFIRMED + else -> Verdict.UNCHANGED + } + } + + /** Record a read-back reply that could not be decoded. */ + fun noteReadBackFailure(f: DeviceConfigReadProbe.ParseFailure) { + val why = when (f) { + DeviceConfigReadProbe.ParseFailure.CRC -> "CRC failed — frame rejected (never decoded)" + DeviceConfigReadProbe.ParseFailure.ENVELOPE -> "not a COMMAND_RESPONSE envelope" + DeviceConfigReadProbe.ParseFailure.WRONG_COMMAND -> "COMMAND_RESPONSE for a different command" + DeviceConfigReadProbe.ParseFailure.TRUNCATED -> "record too short to hold a response" + } + _trace.add("read-back reply not decoded: $why") + verdict = Verdict.UNDECODABLE + } + + /** Record the strap answering nothing at all to the read-back. */ + fun noteReadBackTimeout(seconds: Int) { + _trace.add("GET_DEVICE_CONFIG_VALUE(121) → no COMMAND_RESPONSE within ${seconds}s") + verdict = Verdict.SILENT + } + + /** One-line summary, suitable for a Settings row. */ + val summary: String + get() = when (verdict) { + Verdict.CONFIRMED -> + "Strap now reports ${DeviceConfigWriteGate.ECG_RAW_DATA_KEY}='$requested' " + + "(read back, not just acked)." + Verdict.UNCHANGED -> + "Write did NOT take: asked for '$requested', strap still reports '${storedValue ?: "?"}'." + Verdict.NOT_CLAIMED -> + "Strap answered the read-back but did not echo the key, so no value is claimed." + Verdict.REFUSED -> + "Strap refused the read-back for this key — the stored value is unknown." + Verdict.SILENT -> "No reply to the read-back — the stored value is unknown." + Verdict.UNDECODABLE -> + "The read-back reply did not decode — the stored value is unknown." + Verdict.PENDING -> "Waiting for the read-back…" + } + + /** The full copyable report. */ + fun render(): String { + val sb = StringBuilder() + sb.append("#891 ECG RAW-DATA GATE — WHOOP MG\n") + sb.append("Key: ${DeviceConfigWriteGate.ECG_RAW_DATA_KEY} ") + sb.append("(the strap's own 115/116 enumeration listed it)\n") + sb.append("Wrote '$requested' via SET_DEVICE_CONFIG_VALUE(119), then read it back with ") + sb.append("GET_DEVICE_CONFIG_VALUE(121). SET_FF_VALUE(120) is never sent from this path, and no ") + sb.append("other device-config key is writable from it.\n") + sb.append("\nVerdict: ${verdict.label} — $summary\n") + sb.append("\nExchange:\n") + for (line in _trace) sb.append(" ").append(line).append("\n") + sb.append("\nWhether this gate actually produces ECG data is UNKNOWN. If it now reads '1' and a ") + sb.append("TOGGLE_LABRADOR listen still yields zero packets, that is a real result for #891 — ") + sb.append("please share this report there either way.\n") + return sb.toString() + } +} diff --git a/android/app/src/main/java/com/noop/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/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index d915dd12a7..9e4734a00c 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -110,6 +110,8 @@ import com.noop.ble.WhoopModel import com.noop.data.DataBackup import com.noop.ingest.RawSensorExport import com.noop.ingest.WhoopCsvExporter +// #891: the ECG-gate row reports the strap's READ-BACK verdict, never the write's own ack. +import com.noop.protocol.EcgRawDataGateReport import com.noop.update.UpdateCheck import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -467,6 +469,14 @@ fun SettingsScreen( var puffinCapture by remember(rev) { mutableStateOf(puffinExperiment.isCaptureEnabled) } var deepData by remember(rev) { mutableStateOf(puffinExperiment.isDeepDataEnabled) } var broadcastHr by remember(rev) { mutableStateOf(puffinExperiment.broadcastHr) } + // #891: the ECG raw-data gate opt-in — a PERSISTENT strap write, so its own switch (like #174/#181). + var ecgRawData by remember(rev) { mutableStateOf(puffinExperiment.ecgRawData) } + // #891: the last write's READ-BACK verdict, and the strap's attested hardware variant. `.UNKNOWN` is + // not MG, so the buttons stay disabled until the hardware itself says otherwise — a plain 5.0 has no + // ECG electrodes. + val ecgGateReport by vm.ble.ecgRawDataGate.collectAsStateWithLifecycle() + val ecgVariant by vm.ble.whoop5VariantFlow.collectAsStateWithLifecycle() + val ecgVariantIsMG = ecgVariant.isMG // "Sleep staging (V2)" — V2 is the DEFAULT for every strap (WHOOP 4 and 5/MG); turn it OFF to fall back // to V1. Model-agnostic, so it lives outside the 5/MG-only card. 4.0 is unvalidated either way (#319/#347). var experimentalSleepV2 by remember { mutableStateOf(puffinExperiment.experimentalSleepV2) } @@ -1853,6 +1863,79 @@ fun SettingsScreen( color = Palette.textTertiary, ) + // --- #891 ECG raw-data gate — the second key this app may write, and MG-only. --- + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + uiString(R.string.l10n_settings_screen_ecg_raw_data_gate_whoop_mg_only), + style = NoopType.subhead, + color = Palette.textPrimary, + modifier = Modifier.weight(1f), + ) + Switch( + checked = ecgRawData, + onCheckedChange = { + ecgRawData = it + puffinExperiment.ecgRawData = it + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Palette.surfaceBase, + checkedTrackColor = Palette.accent, + uncheckedThumbColor = Palette.textSecondary, + uncheckedTrackColor = Palette.surfaceInset, + uncheckedBorderColor = Palette.hairline, + ), + modifier = Modifier.semantics { + contentDescription = + uiString(R.string.l10n_settings_screen_ecg_raw_data_gate_whoop_mg_only) + }, + ) + } + Text( + uiString(R.string.l10n_settings_screen_ecg_gate_blurb), + style = NoopType.caption, + color = Palette.textTertiary, + ) + if (ecgRawData) { + Text( + uiString(R.string.l10n_settings_screen_ecg_gate_persistent_warning), + style = NoopType.caption, + color = Palette.statusWarning, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + NoopButton( + text = uiString(R.string.l10n_settings_screen_ecg_gate_turn_on), + kind = NoopButtonKind.Primary, + // Same encrypted-bond requirement the R22 writes have, plus a strap that has + // positively attested itself an MG. Not wear-gated: this stores a value. + enabled = live.bonded && ecgVariantIsMG, + onClick = { vm.ble.setEcgRawDataGate(true) }, + ) + NoopButton( + text = uiString(R.string.l10n_settings_screen_ecg_gate_turn_off), + kind = NoopButtonKind.Secondary, + enabled = live.bonded && ecgVariantIsMG, + onClick = { vm.ble.setEcgRawDataGate(false) }, + ) + } + // The read-back — the ONLY thing reported as a result. The write's own ack goes to the + // strap log for the record and is deliberately not surfaced as an outcome here. + ecgGateReport?.let { report -> + Text( + report.summary, + style = NoopType.caption, + color = if (report.verdict == EcgRawDataGateReport.Verdict.CONFIRMED) { + Palette.statusPositive + } else { + Palette.textSecondary + }, + ) + } + } + // --- R22 deep-data unlock — the one probe that writes to the strap. (#174) --- Row( modifier = Modifier.fillMaxWidth(), diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 5d6589191f..3ec1297198 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1796,6 +1796,11 @@ Dein Smart-Wecker hat gerade geklingelt. Akku-Hinweise Hinweise, wenn der Strap-Akku schwach oder voll geladen ist. + ECG-Rohdatensperre (nur WHOOP MG) + Dein Band hat seine eigenen Geräte-Konfigurationsschlüssel aufgelistet, darunter enable_raw_data_w_ecg. Auf einem MG ohne ECG-Abo steht er auf \'0\' — während alle drei ECG-Befehle mit SUCCESS antworten und trotzdem keine Daten senden (#891). Das ist die naheliegendste Vermutung, was ECG verschlossen hält. Ob das Umlegen tatsächlich ECG-Daten liefert, weiß niemand: genau das herauszufinden ist der Zweck, und „immer noch nichts“ ist ein brauchbares Ergebnis, das sich zu posten lohnt (#891). + Dies schreibt eine Einstellung, die AUF DEINEM BAND BLEIBT, bis du sie zurücksetzt — es ist keine App-Einstellung, und das Schließen von NOOP macht sie nicht rückgängig. „Sperre ausschalten“ schreibt mit einem Tippen wieder \'0\'. Es wird ausschließlich dieser eine Schlüssel geschrieben; die anderen sechs, die dein Band aufgelistet hat, werden nie angefasst. + Sperre einschalten (\'1\' schreiben) + Sperre ausschalten (\'0\' schreiben) Energiesparmodus beruhigt die Anzeigen, übersetzte Android-Benachrichtigungen und sofort geladene Diagramme Kein SpO₂-Import- oder Health-Wert Nur Rohwerte — Import erforderlich diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 72a951b3c8..6d22a73da8 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1781,6 +1781,11 @@ Tu alarma inteligente acaba de sonar. Alertas de batería Avisos cuando la batería de la pulsera está baja o completamente cargada. + Bloqueo de datos ECG sin procesar (solo WHOOP MG) + Tu correa ha enumerado sus propias claves de configuración, y una de ellas es enable_raw_data_w_ecg. En un MG sin suscripción de ECG vale \'0\', mientras que los tres comandos de ECG responden SUCCESS y no envían dato alguno (#891). Es la hipótesis principal sobre qué mantiene el ECG cerrado. Nadie sabe si cambiarla produce realmente datos de ECG: averiguarlo es el objetivo, y «sigue sin haber nada» es una respuesta útil que merece publicarse en #891. + Esto escribe un ajuste que PERMANECE EN TU CORREA hasta que lo cambies de vuelta: no es una preferencia de la app y cerrar NOOP no lo deshace. «Desactivar bloqueo» vuelve a escribir \'0\' con un solo toque. Solo se escribe esta clave; las otras seis que enumeró tu correa nunca se tocan. + Activar bloqueo (escribir \'1\') + Desactivar bloqueo (escribir \'0\') El ahorro de batería calma los indicadores, notificaciones de Android traducidas y gráficos instantáneos Sin importación de SpO₂ ni valor de Health Solo valores brutos — requiere importación diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index dbc0f5bc0e..0c674912db 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1781,6 +1781,11 @@ Votre alarme intelligente vient de sonner. Alertes de batterie Alertes lorsque la batterie du bracelet est faible ou complètement chargée. + Verrou des données ECG brutes (WHOOP MG uniquement) + Votre bracelet a énuméré ses propres clés de configuration, dont enable_raw_data_w_ecg. Sur un MG sans abonnement ECG, elle vaut « 0 » — alors que les trois commandes ECG répondent SUCCESS sans envoyer la moindre donnée (#891). C\'est l\'hypothèse principale sur ce qui maintient l\'ECG fermé. Personne ne sait si la basculer produit réellement des données ECG : le savoir est justement le but, et « toujours rien » est une réponse utile à publier sur #891. + Ceci écrit un réglage qui RESTE SUR VOTRE BRACELET jusqu\'à ce que vous le remettiez — ce n\'est pas une préférence de l\'app, et fermer NOOP ne l\'annule pas. « Désactiver le verrou » réécrit « 0 » en une seule touche. Seule cette clé est écrite ; les six autres que votre bracelet a énumérées ne sont jamais touchées. + Activer le verrou (écrire « 1 ») + Désactiver le verrou (écrire « 0 ») L\'économiseur de batterie fige les jauges, notifications Android traduites et graphiques instantanés Aucune importation SpO₂ ni valeur Santé Valeurs brutes uniquement — importation requise diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index 90b10c8bda..92be06e730 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -1775,6 +1775,11 @@ O teu alarme inteligente acabou de tocar. Alertas de bateria Avisos quando a bateria da correia está fraca ou totalmente carregada. + Bloqueio de dados ECG em bruto (apenas WHOOP MG) + A sua pulseira listou as suas próprias chaves de configuração, e uma delas é enable_raw_data_w_ecg. Num MG sem subscrição de ECG lê \'0\' — enquanto os três comandos de ECG respondem SUCCESS e não enviam dados nenhuns (#891). Esta é a principal hipótese para o que mantém o ECG fechado. Ninguém sabe se mudá-la produz mesmo dados de ECG: descobrir é o objetivo, e «continua sem nada» é uma resposta útil que vale a pena publicar em #891. + Isto escreve uma definição que FICA NA SUA PULSEIRA até a repor — não é uma preferência da aplicação e fechar o NOOP não a desfaz. «Desativar bloqueio» volta a escrever \'0\' com um só toque. Só esta chave é escrita; as outras seis que a sua pulseira listou nunca são tocadas. + Ativar bloqueio (escrever \'1\') + Desativar bloqueio (escrever \'0\') A poupança de bateria acalma os indicadores, notificações Android traduzidas e gráficos instantâneos Sem importação de SpO₂ ou valor de saúde Apenas valores brutos — requer importação diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index a44a94714d..3a85700e83 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1709,6 +1709,11 @@ 你的智能闹钟刚刚响了。 电量提醒 当手环电量低或已充满时提醒。 + ECG 原始数据开关(仅限 WHOOP MG) + 你的手环列出了它自己的设备配置键,其中之一是 enable_raw_data_w_ecg。在没有 ECG 订阅的 MG 上它读作 \'0\',而三条 ECG 命令都回应 SUCCESS 却完全不发送数据 (#891)。这是目前对「什么在挡住 ECG」最有力的猜测。翻转它是否真能产生 ECG 数据,没有人知道:弄清楚正是目的,而「仍然没有」同样是值得发到 #891 的有用答案。 + 这会写入一项会一直留在你手环上的设置,直到你改回来——它不是应用内的偏好设置,关闭 NOOP 也不会撤销。「关闭开关」一键即可再写入 \'0\'。全程只写入这一个键;你手环列出的另外六个从不触碰。 + 打开开关(写入 \'1\') + 关闭开关(写入 \'0\') 省电模式让仪表静止、Android 通知已翻译、图表秒开 无 SpO₂ 导入或健康数值 仅原始计数 — 需导入 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 717be9419d..dc3a5e83e6 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1807,6 +1807,11 @@ Your smart alarm just went off. Battery alerts Alerts when the strap battery is low or fully charged. + ECG raw-data gate (WHOOP MG only) + Your strap listed its own device-config keys, and one of them is enable_raw_data_w_ecg. On an MG with no ECG subscription it reads \'0\' — while all three ECG commands answer SUCCESS and send no data at all (#891). This is the leading guess for what\'s holding ECG shut. Nobody knows whether flipping it actually produces ECG data: finding out is the point, and \"still nothing\" is a useful answer worth posting to #891. + This writes a setting that STAYS ON YOUR STRAP until you change it back — it isn\'t an app preference, and closing NOOP won\'t undo it. \"Turn gate off\" writes \'0\' again, in one tap. Only this one key is ever written; the other six your strap listed are never touched. + Turn gate on (write \'1\') + Turn gate off (write \'0\') Battery saver quiets the gauges, translated Android notifications, and instant chart loads No SpO₂ import or Health value Raw counts only — needs an import 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..84e719a2e1 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,319 +267,469 @@ 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) - } - 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. - assertEquals( - "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this " + - "firmware — rejected as UNSUPPORTED", - rep.verdict, - ) - assertTrue(rep.render().contains("neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121)")) + 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) } - /** - * 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. - */ + /** A successful enumeration is evidence about what the firmware HOLDS, not proof of what it would + * ACCEPT — and it says nothing at all about the feature-flag namespace, where most of the catalogue + * is aimed. So a run can be asked to sweep anyway, and then the candidate steps must actually happen. */ @Test - fun 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) - assertEquals( - "no read verb answered — GET_FF_VALUE(128) served no reply in 8s — unconfirmed; " + - "GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed", - rep.verdict, + fun aForcedSweepAsksTheCandidatesEvenAfterEnumerationSucceeds() { + val report = DeviceConfigReadProbeReport( + DeviceFamily.WHOOP5, + listOf("enable_r22_packets", "hr_ch_switching"), + ConfigKeySweep.batch(0, 2), + forceCandidateSweep = true, ) - 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"), + assertTrue(report.runsCandidateSweep) + + report.nextStep()!! + report.noteEnumerationStart(startReply(enumStart(1, 10, 1))) + report.nextStep()!! + // The Broadcast-HR key: enumerated, and one NOOP already had — so newKeysFound stays empty and the + // headline is free to report what the sweep established rather than what enumeration found. + assertTrue(report.noteEnumerationNext(nextReply(enumNext(1, "whoop_live_hr_in_adv_ind_pkt")))) + report.nextStep()!! + assertFalse(report.noteEnumerationNext(nextReply(enumNext(0xFF, null, validKey = false)))) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.ANSWERED, report.enumerationVerb) + assertTrue(report.enumeratedKeys.isNotEmpty()) + + val candidates = mutableListOf() + var guard = 0 + while (guard < 200) { + val step = report.nextStep() ?: break + guard += 1 + val isCandidate = step.group == DeviceConfigReadProbeReport.Group.CANDIDATE + if (isCandidate) candidates.add(step.key) + report.noteReply(valueReply(if (isCandidate) 0 else 1, echoRecord(step.key, 0x30)), step) + } + assertEquals( + ConfigKeySweep.batch(0, 2).candidates.map { it.key }, + candidates.distinct(), ) - 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)")) + val text = report.render() + assertTrue(text.contains("FULL SWEEP: asked even though enumeration succeeded")) + assertFalse(text.contains("skipped — the strap enumerated its own device-config keys")) + assertTrue(report.verdict, report.verdict.contains("clean negative")) + assertTrue(report.verdict, report.verdict.contains("enumerated in full")) } - /** - * 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. - */ + /** The routing fix itself: every candidate is asked through BOTH value verbs when both answered. */ @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) - assertEquals( - "no read verb answered — GET_FF_VALUE(128) refused by firmware (UNSUPPORTED); " + - "GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed", - rep.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)"), - ) + fun everyCandidateIsAskedThroughEveryAnsweringVerb() { + val (report, first) = driveToCandidates(2) + var step = first + val byKey = mutableMapOf>() + while (step != null) { + byKey.getOrPut(step.key) { mutableListOf() }.add(step.opcode) + report.noteReply(valueReply(0, ByteArray(0)), step) + step = report.nextStep() + } + for ((key, opcodes) in byKey) { + assertEquals("$key must be asked through both verbs", setOf(121, 128), opcodes.toSet()) + } + val text = report.render() + assertTrue(text, text.contains("121=unknown · 128=unknown")) + assertTrue(text, text.contains("each name asked through 2 verb(s)")) } - /** - * An undecodable reply is affirmative evidence the strap DID transmit, so "not served by this - * firmware" states the opposite of what the run observed. - */ + /** 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 anUndecodableReplyIsNotReportedAsUnserved() { - val rep = report(candidates = emptyList()) - rep.noteFailure(DeviceConfigReadProbe.ParseFailure.CRC, rep.nextStep()!!) - rep.noteFailure(DeviceConfigReadProbe.ParseFailure.ENVELOPE, rep.nextStep()!!) + 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(DeviceConfigReadProbeReport.VerbStatus.UNDECODABLE, rep.featureFlagVerb) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNDECODABLE, rep.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, + listOf("whoop_live_hr_in_adv_ind_pkt", "whoop_sleep_coach_enabled"), + report.enumeratedKeys, ) - assertFalse(rep.render().contains("served by this firmware")) + 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) + } + 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")) } - /** - * A probe that ended before either verb went out says so, rather than reporting an empty run as a - * finding about the firmware. - */ + /** 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 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, + fun anUndecodableNameIsSteppedOverRatherThanEndingTheWalk() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(1, 10, 3))) + report.nextStep() + assertTrue( + report.noteEnumerationNext( + FeatureFlagProbe.NextResponse(1, 10, 1, validKey = true, key = null), + ), ) + report.nextStep() + assertTrue(report.noteEnumerationNext(nextReply(enumNext(2, "whoop_after_the_bad_one")))) + assertEquals(listOf("whoop_after_the_bad_one"), report.enumeratedKeys) + assertEquals(1, report.enumerationSkipped) } + /** A refused enumeration is the case the guessing fallback exists for — and it must cost exactly one + * round-trip, not one per key. */ @Test - fun anAnsweringFeatureFlagVerbWalksTheFifteenRemainingKnownFlags() { - val rep = report() - val s128 = rep.nextStep()!! - rep.noteReply( - DeviceConfigReadProbe.ValueResponse(1, echoRecord(s128.key, 0x32, byteArrayOf(0x01))), s128, + fun anUnsupportedEnumerationCostsOneRoundTripAndOpensTheFallback() { + val report = smallReport() + val s1 = report.nextStep()!! + assertEquals(115, s1.opcode) + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, report.enumerationVerb) + + val s2 = report.nextStep()!! + assertEquals( + "116 must not be asked once 115 refused", + DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD, + s2.opcode, ) - val s121 = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(3, byteArrayOf(0)), s121) + assertEquals(DeviceConfigReadProbeReport.Group.DISCOVERY, s2.group) + } - val flagSteps = mutableListOf() - val candidateSteps = mutableListOf() - while (true) { - val step = rep.nextStep() ?: break - assertEquals("the refused verb must never be sent again", 128, step.opcode) - when (step.group) { - DeviceConfigReadProbeReport.Group.KNOWN_FLAG -> flagSteps.add(step.key) - DeviceConfigReadProbeReport.Group.CANDIDATE -> candidateSteps.add(step.key) - DeviceConfigReadProbeReport.Group.DISCOVERY -> throw AssertionError("discovery is over") - } - rep.noteReply( - DeviceConfigReadProbe.ValueResponse(1, echoRecord(step.key, 0x31, byteArrayOf(0x01))), step, - ) - } - assertEquals(flagKeys.drop(1), flagSteps) - assertEquals(DeviceConfigReadProbe.OXYGEN_CANDIDATE_KEYS, candidateSteps) - assertEquals(2 + 15 + DeviceConfigReadProbe.OXYGEN_CANDIDATE_KEYS.size, rep.steps) + /** A silent enumeration retires the pair after ONE timeout rather than one per entry. */ + @Test + fun aSilentEnumerationRetiresAfterOneTimeout() { + val report = smallReport() + val s1 = report.nextStep()!! + report.noteTimeout(s1, 8) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.SILENT, report.enumerationVerb) + assertEquals(DeviceConfigReadProbeReport.Group.DISCOVERY, report.nextStep()!!.group) + assertTrue(report.render().contains("(none — no reply to 115)")) } @Test - fun candidatesPreferTheDeviceConfigVerbWhenBothAnswer() { - val rep = report(flags = listOf("only_flag"), candidates = listOf("enable_spo2")) - val a = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(1, echoRecord(a.key, 0x32)), a) - val b = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(1, echoRecord(b.key, 0x31)), b) + fun anUndecodableEnumerationReplyRetiresIt() { + val report = smallReport() + val s1 = report.nextStep()!! + report.noteFailure(DeviceConfigReadProbe.ParseFailure.CRC, s1) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNDECODABLE, report.enumerationVerb) + assertEquals(DeviceConfigReadProbeReport.Group.DISCOVERY, report.nextStep()!!.group) + assertEquals("CRC failed — frame rejected (never decoded)", report.stopReason) + } + + // ---- Cross-namespace ---- - val c = rep.nextStep()!! - assertEquals(DeviceConfigReadProbeReport.Group.CANDIDATE, c.group) - assertEquals(121, c.opcode) - assertEquals("enable_spo2", c.key) + /** Two round-trips that settle whether the namespaces are separate — the result shapes every later + * sweep, so it is asked of each verb that answered. */ + @Test + fun crossNamespaceIsAskedOfEachAnsweringVerb() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) + + val d1 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord(d1.key, 0x32)), d1) + val d2 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord(d2.key, 0x30)), d2) + + val x1 = report.nextStep()!! + assertEquals(DeviceConfigReadProbeReport.Group.CROSS_NAMESPACE, x1.group) + assertEquals(DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD, x1.opcode) + assertEquals(DeviceConfigReadProbe.DEVICE_CONFIG_DISCOVERY_KEY, x1.key) + report.noteReply(valueReply(0, ByteArray(0)), x1) + + val x2 = report.nextStep()!! + assertEquals(DeviceConfigReadProbeReport.Group.CROSS_NAMESPACE, x2.group) + assertEquals(DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD, x2.opcode) + assertEquals("enable_r22_packets", x2.key) + report.noteReply(valueReply(0, ByteArray(0)), x2) + + assertEquals(ConfigKeySweep.Existence.UNKNOWN, report.featureFlagVerbOnDeviceConfigKey) + assertEquals(ConfigKeySweep.Existence.UNKNOWN, report.deviceConfigVerbOnFlagKey) + assertTrue(report.render().contains("the namespaces are separate")) } + /** If one verb turns out to serve both namespaces, everything afterwards goes through it — halving + * the work every future sweep needs, on evidence gathered in the same run. */ @Test - fun thePlanIsCappedEvenWithAnAbsurdKeyList() { - val many = (0 until 500).map { "key_$it" } - val rep = report(family = DeviceFamily.WHOOP4, flags = many, candidates = many) - var seen = 0 + fun aVerbShownToServeBothNamespacesCarriesEverythingAfterwards() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) + val d1 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord(d1.key, 0x32)), d1) + val d2 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord(d2.key, 0x30)), d2) + val x1 = report.nextStep()!! + report.noteReply(valueReply(0, ByteArray(0)), x1) + val x2 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord(x2.key, 0x32)), x2) + assertEquals(ConfigKeySweep.Existence.EXISTS, report.deviceConfigVerbOnFlagKey) + + val k1 = report.nextStep()!! + assertEquals( + "the verb proved to serve both namespaces carries the rest of the plan", + DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD, + k1.opcode, + ) + assertTrue(report.render().contains("GET_DEVICE_CONFIG_VALUE(121) serves BOTH namespaces.")) + } + + // ---- The sweep ---- + + /** Drive the plan with enumeration refused, stopping at the FIRST candidate step and handing it back + * alongside the report (a pulled step cannot be pushed back). */ + private fun driveToCandidates(limit: Int): Pair { + val report = smallReport(limit) + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) while (true) { - val step = rep.nextStep() ?: break - seen += 1 - assertTrue("the plan must terminate", seen <= DeviceConfigReadProbe.MAX_STEPS + 1) - rep.noteReply(DeviceConfigReadProbe.ValueResponse(null, echoRecord(step.key, 0x31)), step) + val step = report.nextStep() ?: return report to null + if (step.group == DeviceConfigReadProbeReport.Group.CANDIDATE) return report to step + report.noteReply(valueReply(1, echoRecord(step.key, 0x32)), step) } - assertEquals(DeviceConfigReadProbe.MAX_STEPS, seen) - assertEquals("safety cap of 64 round-trips reached", rep.stopReason) } + /** A fully-negative sweep is a RESULT, and the verdict must say so rather than reading like a + * failure. */ @Test - fun anUndecodableReplyRetiresTheVerb() { - val rep = report(candidates = emptyList()) - val step = rep.nextStep()!! - rep.noteFailure(DeviceConfigReadProbe.ParseFailure.CRC, step) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNDECODABLE, rep.featureFlagVerb) - val next = rep.nextStep()!! - assertEquals("the undecodable verb is not retried", 121, next.opcode) - rep.noteTimeout(next, 8) - assertNull(rep.nextStep()) - assertEquals("CRC failed — frame rejected (never decoded)", rep.stopReason) + fun aFullyNegativeSweepIsACleanNegativeVerdict() { + val (report, first) = driveToCandidates(2) + var step = first + val asked = mutableListOf() + while (step != null) { + assertEquals(DeviceConfigReadProbeReport.Group.CANDIDATE, step.group) + asked.add(step.key) + report.noteReply(valueReply(0, ByteArray(0)), step) + step = report.nextStep() + } + // Each NAME is asked through every verb that answered — here both, so each appears twice. + assertEquals(listOf("enable_sig1", "enable_sig1", "enable_sig2", "enable_sig2"), asked) + assertEquals(2, asked.distinct().size) + assertEquals( + "asked 2 candidate key name(s); this firmware has none of them (a clean negative)", + report.verdict, + ) + assertTrue(report.newKeysFound.isEmpty()) } + /** And a hit is the headline, named in the verdict so a strap log's first line carries the finding. */ @Test - fun aVerdictAlreadyReachedIsNotRewrittenByALaterReply() { - val rep = report(flags = listOf("a", "b"), candidates = emptyList()) - val first = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(1, echoRecord("a", 0x31)), first) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.ANSWERED, rep.featureFlagVerb) - val second = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(3, byteArrayOf(0)), second) - assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, rep.deviceConfigVerb) - rep.noteReply(DeviceConfigReadProbe.ValueResponse(1, byteArrayOf(0)), second) - assertEquals( - "a refusal is not upgraded away", - DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, rep.deviceConfigVerb, - ) + fun aCandidateThatExistsBecomesTheHeadline() { + val (report, first) = driveToCandidates(2) + val c1 = first!! + report.noteReply(valueReply(1, echoRecord(c1.key, 0x31)), c1) + val c2 = report.nextStep()!! + report.noteReply(valueReply(0, ByteArray(0)), c2) + assertEquals(listOf("enable_sig1"), report.newKeysFound) + assertEquals("1 config key name(s) found that NOOP did not have: enable_sig1", report.verdict) + assertTrue(report.trace.any { it.contains("enable_sig1") && it.contains("exists") }) + assertFalse(report.trace.any { it.contains("enable_sig2") }) } - // MARK: - Report + /** No silent truncation: the report states how many names it asked, how many the catalogue holds, and + * how many remain untested, plus where the next run resumes. */ + @Test + fun theReportStatesTestedAndUntestedCountsAndWhereToResume() { + val (report, first) = driveToCandidates(2) + var step = first + while (step != null) { + report.noteReply(valueReply(0, ByteArray(0)), step) + step = report.nextStep() + } + val text = report.render() + val total = ConfigKeySweep.CATALOGUE.size + assertTrue(text, text.contains("(2 asked of $total in the catalogue; ${total - 2} untested)")) + assertTrue(text, text.contains("Run the probe again to continue from catalogue entry 3.")) + } + /** The default catalogue is smaller than one run's budget, so a real run reports nothing untested. */ @Test - fun candidateKeysAreLabelledAsGuesses() { - val rep = report(flags = listOf("f"), candidates = listOf("enable_spo2")) - val a = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(1, echoRecord("f", 0x32)), a) - val b = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(1, echoRecord(b.key, 0x31)), b) - val c = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(0, byteArrayOf(0)), c) - val text = rep.render() - assertTrue(text.contains("Candidate oxygen keys — GUESSES, never observed on a wire or in any table")) - assertTrue(text.contains("enable_spo2")) - assertTrue(text.contains("no value (result=FAILURE(0))")) + fun aFullRunOfTodaysCatalogueLeavesNothingUntested() { + val report = report() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) + var candidates = 0 + var steps = 0 + while (steps < DeviceConfigReadProbe.MAX_STEPS) { + val step = report.nextStep() ?: break + steps += 1 + val isCandidate = step.group == DeviceConfigReadProbeReport.Group.CANDIDATE + if (isCandidate) candidates += 1 + report.noteReply(valueReply(if (isCandidate) 0 else 1, echoRecord(step.key, 0x32)), step) + } + // One round-trip per (name x answering verb) — both verbs answered here. + assertEquals(ConfigKeySweep.CATALOGUE.size * 2, candidates) + assertNull("a full default run must not hit the safety cap", report.stopReason) + assertTrue(report.render().contains("none untested")) } + /** The safety cap still binds, whatever the plan holds. */ @Test - fun oxygenCandidateListIsShortAndOxygenNamed() { - val keys = DeviceConfigReadProbe.OXYGEN_CANDIDATE_KEYS - assertTrue(keys.isNotEmpty()) - assertTrue("keep the guess list short — it is a guess list", keys.size <= 12) - assertEquals(keys.size, keys.toSet().size) - for (k in keys) { - assertTrue( - "$k does not read as oxygen-related", - k.contains("spo2") || k.contains("oxygen") || k.contains("pulse_ox"), - ) - assertTrue( - "$k would be truncated by the 32-byte name field", - k.toByteArray(Charsets.UTF_8).size <= DeviceConfigReadProbe.NAME_FIELD_BYTES, - ) + fun thePlanIsCappedEvenWhenTheStrapEnumeratesForever() { + val report = smallReport() + report.nextStep() + report.noteEnumerationStart(startReply(enumStart(1, 10, 9999))) + var seen = 0 + while (seen < 500) { + val step = report.nextStep() ?: break + seen += 1 + if (step.group == DeviceConfigReadProbeReport.Group.ENUMERATE) { + report.noteEnumerationNext(nextReply(enumNext(1, "whoop_stuck"))) + } else { + report.noteReply(valueReply(0, ByteArray(0)), step) + } } + assertTrue(report.steps <= DeviceConfigReadProbe.MAX_STEPS) + assertNotNull(report.stopReason) } - /** - * GOLDEN: the exact rendered report, byte-for-byte. Its Swift twin - * (`DeviceConfigReadProbeTests.testGoldenReportIsByteIdenticalAcrossPlatforms`) asserts the SAME - * literal, so a strap log reads identically on either platform. - * - * The first record's trailing `00`, and the seven-byte UNSUPPORTED record, are the puffin envelope's - * 4-byte inner padding showing through — which is exactly why the value is read as "the byte after - * the echoed name field" and not "the last byte of the record". - */ + // ---- Report ---- + + /** Byte-for-byte golden, asserted identically by the Swift twin, so a shared strap log reads the same + * either side and a wording drift fails here rather than in a user's log. */ @Test fun goldenReportIsByteIdenticalAcrossPlatforms() { - val rep = report(flags = listOf("enable_r22_packets", "hr_ch_switching"), candidates = listOf("enable_spo2")) + val report = smallReport() + val s1 = report.nextStep()!! + assertEquals(115, s1.opcode) + report.noteEnumerationStart(startReply(enumStart(3, 0, 0))) + + val s2 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord("enable_r22_packets", 0x32)), s2) + val s3 = report.nextStep()!! + report.noteReply( + valueReply(1, echoRecord(DeviceConfigReadProbe.DEVICE_CONFIG_DISCOVERY_KEY, 0x30)), + s3, + ) + val s4 = report.nextStep()!! + report.noteReply(valueReply(0, byteArrayOf(0x01, 0x00)), s4) + val s5 = report.nextStep()!! + report.noteReply(valueReply(0, byteArrayOf(0x01, 0x00)), s5) + val s6 = report.nextStep()!! + report.noteReply(valueReply(1, echoRecord("hr_ch_switching", 0x32)), s6) + // Two names × two answering verbs = four candidate round-trips. + repeat(4) { + val c = report.nextStep()!! + assertEquals(DeviceConfigReadProbeReport.Group.CANDIDATE, c.group) + report.noteReply(valueReply(0, byteArrayOf(0x01, 0x00)), c) + } + assertNull(report.nextStep()) + + assertEquals(GOLDEN_REPORT, report.render()) + } - val s1 = rep.nextStep()!! - val f1 = whoop5Response(128, payload(1, echoRecord("enable_r22_packets", 0x32, byteArrayOf(0x01)))) - rep.noteReply(DeviceConfigReadProbe.parse(f1, DeviceFamily.WHOOP5, 128).value!!, s1) + private companion object { + const val GOLDEN_REPORT = """#103 CONFIG KEY PROBE — WHOOP 5/MG +Read-only: START_DEVICE_CONFIG_KEY_EXCHANGE(115), SEND_NEXT_DEVICE_CONFIG(116), GET_DEVICE_CONFIG_VALUE(121), GET_FF_VALUE(128). +No value is written; SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path. +Oracle: result=SUCCESS(1) means the key NAME exists; result=FAILURE(0) means the firmware has no such key. - val s2 = rep.nextStep()!! - val f2 = whoop5Response(121, payload(3, byteArrayOf(0, 0, 0, 0))) - rep.noteReply(DeviceConfigReadProbe.parse(f2, DeviceFamily.WHOOP5, 121).value!!, s2) +Verdict: asked 2 candidate key name(s); this firmware has none of them (a clean negative) - val s3 = rep.nextStep()!! - rep.noteReply( - DeviceConfigReadProbe.ValueResponse(1, echoRecord("hr_ch_switching", 0x32, byteArrayOf(0x01))), s3, - ) +Verbs: + device-config enumerate(115/116) unsupported + GET_FF_VALUE(128) answered + GET_DEVICE_CONFIG_VALUE(121) answered + +Device-config keys the strap listed for itself (115/116) (0): + (none — the firmware refused 115 as UNSUPPORTED) + +Namespace separation: + 128 asked for a device-config key unknown + 121 asked for a feature-flag key unknown + ⇒ the namespaces are separate: neither verb sees the other's keys. + +Discovery — one round-trip per value verb against a key it should know (2): + 1. enable_r22_packets = '2' (0x32) + 2. whoop_live_hr_in_adv_ind_pkt = '0' (0x30) + +Known key values (the flags NOOP writes, plus anything enumeration returned) (1): + 1. hr_ch_switching = '2' (0x32) + +Candidate key names — GUESSES, never observed on a wire or in any table (2 asked of 54 in the catalogue; 52 untested): + 0 exist · 2 do not · 0 inconclusive (each name asked through 2 verb(s)) + + sig series (T8) — the firmware numbers its signal chains; sig11/sig12 are the two we have (2): + 1. enable_sig1 121=unknown · 128=unknown + 2. enable_sig2 121=unknown · 128=unknown + + Run the probe again to continue from catalogue entry 3. - val s4 = rep.nextStep()!! - rep.noteReply(DeviceConfigReadProbe.ValueResponse(0, byteArrayOf(0x01, 0x00)), s4) - assertNull(rep.nextStep()) - - val golden = "#103 DEVICE-CONFIG READ PROBE — WHOOP 5/MG\n" + - "Read-only: GET_DEVICE_CONFIG_VALUE(121) + GET_FF_VALUE(128). No value is written; " + - "SET_DEVICE_CONFIG_VALUE(119) and SET_FF_VALUE(120) are never sent from this path.\n" + - "Follow-up to the #761 enumeration probe: that one asked for key NAMES, this asks for VALUES.\n" + - "\n" + - "Verdict: 1 of 2 read verbs answered; read 2 config value(s)\n" + - "\n" + - "Read verbs:\n" + - " GET_FF_VALUE(128) answered\n" + - " GET_DEVICE_CONFIG_VALUE(121) unsupported\n" + - "\n" + - "Discovery — one round-trip per verb against a key it should know (2):\n" + - " 1. enable_r22_packets = '2' (0x32)\n" + - " 2. whoop_live_hr_in_adv_ind_pkt — no value (result=UNSUPPORTED(3))\n" + - "\n" + - "Known feature-flag values (names NOOP already writes; values never read before) (1):\n" + - " 1. hr_ch_switching = '2' (0x32)\n" + - "\n" + - "Candidate oxygen keys — GUESSES, never observed on a wire or in any table (1):\n" + - " 1. enable_spo2 — no value (result=FAILURE(0))\n" + - "\n" + - "Exchange:\n" + - " GET_FF_VALUE(128) key=\"enable_r22_packets\" → result=SUCCESS(1) value='2' (0x32) " + - "record=[01 65 6e 61 62 6c 65 5f 72 32 32 5f 70 61 63 6b 65 74 73 00 00 00 00 00 00 00 00 00 " + - "00 00 00 00 00 32 00]\n" + - " GET_DEVICE_CONFIG_VALUE(121) key=\"whoop_live_hr_in_adv_ind_pkt\" → " + - "result=UNSUPPORTED(3) record=[00 00 00 00 00 00 00]\n" + - " GET_FF_VALUE(128) key=\"hr_ch_switching\" → result=SUCCESS(1) value='2' (0x32) " + - "record=[01 68 72 5f 63 68 5f 73 77 69 74 63 68 69 6e 67 00 00 00 00 00 00 00 00 00 00 00 00 " + - "00 00 00 00 00 32]\n" + - " GET_FF_VALUE(128) key=\"enable_spo2\" → result=FAILURE(0) record=[01 00]\n" - assertEquals(golden, rep.render()) +Exchange: + START_DEVICE_CONFIG_KEY_EXCHANGE(115) → result=UNSUPPORTED(3) — the firmware does not serve this verb + GET_FF_VALUE(128) key="enable_r22_packets" → result=SUCCESS(1) exists value='2' (0x32) record=[65 6e 61 62 6c 65 5f 72 32 32 5f 70 61 63 6b 65 74 73 00 00 00 00 00 00 00 00 00 00 00 00 00 00 32] + GET_DEVICE_CONFIG_VALUE(121) key="whoop_live_hr_in_adv_ind_pkt" → result=SUCCESS(1) exists value='0' (0x30) record=[77 68 6f 6f 70 5f 6c 69 76 65 5f 68 72 5f 69 6e 5f 61 64 76 5f 69 6e 64 5f 70 6b 74 00 00 00 00 30] + GET_FF_VALUE(128) key="whoop_live_hr_in_adv_ind_pkt" → result=FAILURE(0) unknown record=[01 00] + GET_DEVICE_CONFIG_VALUE(121) key="enable_r22_packets" → result=FAILURE(0) unknown record=[01 00] + GET_FF_VALUE(128) key="hr_ch_switching" → result=SUCCESS(1) exists value='2' (0x32) record=[68 72 5f 63 68 5f 73 77 69 74 63 68 69 6e 67 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 32] +""" } } diff --git a/android/app/src/test/java/com/noop/protocol/DeviceConfigWriteGateTest.kt b/android/app/src/test/java/com/noop/protocol/DeviceConfigWriteGateTest.kt new file mode 100644 index 0000000000..a4e632fcf5 --- /dev/null +++ b/android/app/src/test/java/com/noop/protocol/DeviceConfigWriteGateTest.kt @@ -0,0 +1,338 @@ +package com.noop.protocol + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * #891: byte-parity twin of the Swift `DeviceConfigWriteGateTests` — the ECG raw-data gate's write + * allowlist, the body it builds, and the mandatory read-back. + * + * The allowlist tests are the point of this file. [DeviceConfigWriteGate.admitsSend] is the SAME predicate + * the 5/MG send path consults, so proving here that it refuses an opcode or a key is proving it about the + * real wire path rather than about a copy of the rule. + */ +class DeviceConfigWriteGateTest { + + // Helpers --------------------------------------------------------------------------------------- + + /** The payload the send path would hold for a device-config write of [key]. */ + private fun payload(key: String, value: Int = 0x31): ByteArray = + byteArrayOf(0x01) + Whoop5Config.deviceConfigBody(key, value) + + /** A real 5/MG COMMAND_RESPONSE frame carrying [record] for command 121, CRC16 header + CRC32 body, + * so [DeviceConfigReadProbe.parse] runs its CRC gate on these fixtures rather than being bypassed. */ + private fun readBackFrame(record: ByteArray, cmd: Int = 121, result: Int = 1): ByteArray { + var inner = byteArrayOf(36, 1, cmd.toByte(), 0x0A, result.toByte()) + record + val pad = (4 - inner.size % 4) % 4 + if (pad > 0) inner += ByteArray(pad) + val declLen = inner.size + 4 + val head = byteArrayOf( + 0xAA.toByte(), 0x01, (declLen and 0xFF).toByte(), ((declLen shr 8) and 0xFF).toByte(), + 0x00, 0x01, + ) + val c16 = Crc.crc16Modbus(head) + val c32 = Crc.crc32(inner) + return head + byteArrayOf((c16 and 0xFF).toByte(), ((c16 shr 8) and 0xFF).toByte()) + inner + + byteArrayOf( + (c32 and 0xFFL).toByte(), ((c32 shr 8) and 0xFFL).toByte(), + ((c32 shr 16) and 0xFFL).toByte(), ((c32 shr 24) and 0xFFL).toByte(), + ) + } + + /** A 121 reply record echoing [key] in a 32-byte NUL-padded field, then [value] as ASCII. */ + private fun echoRecord(key: String, value: String): ByteArray { + val field = ByteArray(DeviceConfigWriteGate.NAME_FIELD_BYTES) + val bytes = key.toByteArray(Charsets.UTF_8) + for (i in 0 until minOf(field.size, bytes.size)) field[i] = bytes[i] + return field + value.toByteArray(Charsets.US_ASCII) + } + + private fun parseReadBack(frame: ByteArray): DeviceConfigReadProbe.ValueResponse { + val parsed = DeviceConfigReadProbe.parse(frame, DeviceFamily.WHOOP5, 121) + return requireNotNull(parsed.value) { "read-back frame should parse" } + } + + // The allowlist: what it admits ------------------------------------------------------------------ + + @Test + fun admitsEcgKeyOnlyWhenOptedInOnAnAttestedMG() { + val p = payload(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + assertTrue(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = true, isMG = true, broadcastHrOptIn = false)) + assertFalse(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = false, isMG = true, broadcastHrOptIn = false)) + assertFalse(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = true, isMG = false, broadcastHrOptIn = false)) + // The Broadcast-HR opt-in must NOT carry the ECG key — the hole the key-aware gate closes. + assertFalse(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = false, isMG = true, broadcastHrOptIn = true)) + } + + @Test + fun admitsBroadcastHrKeyOnlyUnderItsOwnOptIn() { + val p = payload(DeviceConfigWriteGate.BROADCAST_HR_KEY) + assertTrue(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = false, isMG = false, broadcastHrOptIn = true)) + assertFalse(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = false, isMG = false, broadcastHrOptIn = false)) + assertFalse(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = true, isMG = true, broadcastHrOptIn = false)) + // Broadcast HR is not MG-gated: it works on a plain 5.0, and must keep doing so. + assertTrue(DeviceConfigWriteGate.admitsSend(119, p, ecgGateOptIn = true, isMG = false, broadcastHrOptIn = true)) + } + + // The allowlist: what it refuses ----------------------------------------------------------------- + + @Test + fun refusesEveryOtherEnumeratedKeyEvenWithBothOptInsOn() { + for (key in DeviceConfigWriteGate.OUT_OF_SCOPE_KEYS) { + assertFalse( + "$key must never be writable from this path", + DeviceConfigWriteGate.admitsSend( + 119, payload(key), ecgGateOptIn = true, isMG = true, broadcastHrOptIn = true, + ), + ) + } + assertEquals(5, DeviceConfigWriteGate.OUT_OF_SCOPE_KEYS.size) + assertEquals( + ( + DeviceConfigWriteGate.OUT_OF_SCOPE_KEYS + + listOf(DeviceConfigWriteGate.ECG_RAW_DATA_KEY, DeviceConfigWriteGate.BROADCAST_HR_KEY) + ).toSet(), + DeviceConfigWriteGate.ENUMERATED_KEYS.toSet(), + ) + assertEquals(7, DeviceConfigWriteGate.ENUMERATED_KEYS.size) + } + + @Test + fun refusesSetFeatureFlagValue120ForEveryKeyAndEveryOptIn() { + val keys = DeviceConfigWriteGate.ENUMERATED_KEYS + Whoop5Config.enableR22Sequence.map { it.name } + for (key in keys) { + for (ecg in listOf(true, false)) { + for (hr in listOf(true, false)) { + assertFalse( + "SET_FF_VALUE(120) must never be admitted (key=$key)", + DeviceConfigWriteGate.admitsSend( + DeviceConfigWriteGate.SET_FF_VALUE_CMD, payload(key), + ecgGateOptIn = ecg, isMG = true, broadcastHrOptIn = hr, + ), + ) + } + } + } + } + + @Test + fun refusesEveryOtherOpcodeInTheWholeByteRange() { + val p = payload(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + val admitted = (0..255).filter { + DeviceConfigWriteGate.admitsSend(it, p, ecgGateOptIn = true, isMG = true, broadcastHrOptIn = true) + } + assertEquals(listOf(DeviceConfigWriteGate.SET_DEVICE_CONFIG_VALUE_CMD), admitted) + } + + @Test + fun refusesUnknownAndMalformedKeys() { + for ((ecg, hr) in listOf(true to true, true to false, false to true, false to false)) { + assertFalse( + DeviceConfigWriteGate.admitsSend( + 119, payload("enable_something_invented"), ecgGateOptIn = ecg, isMG = true, broadcastHrOptIn = hr, + ), + ) + for (near in listOf("enable_raw_data_w_ec", "enable_raw_data_w_ecg2", "ENABLE_RAW_DATA_W_ECG")) { + assertFalse( + "$near must not pass", + DeviceConfigWriteGate.admitsSend( + 119, payload(near), ecgGateOptIn = ecg, isMG = true, broadcastHrOptIn = hr, + ), + ) + } + } + val good = payload(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + val malformed = listOf( + ByteArray(0), + byteArrayOf(0x01), + good.copyOfRange(1, good.size), + byteArrayOf(0x02) + good.copyOfRange(1, good.size), + good.copyOfRange(0, 20), + ) + for (bad in malformed) { + assertFalse( + DeviceConfigWriteGate.admitsSend(119, bad, ecgGateOptIn = true, isMG = true, broadcastHrOptIn = true), + ) + } + } + + @Test + fun keyNameParsingRejectsNonNulPaddingAndNonAscii() { + // Junk AFTER the NUL terminator is not a NUL-padded name field — nothing is claimed. + val body = payload(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + body[1 + DeviceConfigWriteGate.ECG_RAW_DATA_KEY.length + 2] = 0x41 + assertNull(DeviceConfigWriteGate.keyNameInSendPayload(body)) + assertFalse( + DeviceConfigWriteGate.admitsSend(119, body, ecgGateOptIn = true, isMG = true, broadcastHrOptIn = true), + ) + // Extending the name itself yields a DIFFERENT name, which the key allowlist then refuses. + val extended = payload(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + extended[1 + DeviceConfigWriteGate.ECG_RAW_DATA_KEY.length] = 0x41 + assertEquals("enable_raw_data_w_ecgA", DeviceConfigWriteGate.keyNameInSendPayload(extended)) + assertFalse( + DeviceConfigWriteGate.admitsSend(119, extended, ecgGateOptIn = true, isMG = true, broadcastHrOptIn = true), + ) + // Binary in the name field is refused, not transliterated. + val binary = payload(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + binary[3] = 0xFF.toByte() + assertNull(DeviceConfigWriteGate.keyNameInSendPayload(binary)) + assertEquals("enable_rfid", DeviceConfigWriteGate.keyNameInSendPayload(payload("enable_rfid"))) + } + + @Test + fun readBackOpcodeIsOnly121() { + assertEquals(listOf(121), (0..255).filter { DeviceConfigWriteGate.isReadBackOpcode(it) }) + assertFalse(DeviceConfigWriteGate.isReadBackOpcode(119)) + assertFalse(DeviceConfigWriteGate.isReadBackOpcode(120)) + assertFalse(DeviceConfigWriteGate.isReadBackOpcode(128)) + } + + // The bytes on the wire -------------------------------------------------------------------------- + + @Test + fun writePayloadIsTheHardwareValidatedBroadcastHrShape() { + val on = DeviceConfigWriteGate.writePayload(true) + assertEquals(1 + 32 + 1, on.size) + assertEquals(0x01, on[0].toInt()) + assertEquals("enable_raw_data_w_ecg", DeviceConfigWriteGate.keyNameInSendPayload(on)) + assertEquals(0x31, on.last().toInt() and 0xFF) + val off = DeviceConfigWriteGate.writePayload(false) + assertEquals(0x30, off.last().toInt() and 0xFF) + // Both directions differ ONLY in the value byte — reversibility is one byte, not a second path. + assertTrue(on.copyOfRange(0, on.size - 1).contentEquals(off.copyOfRange(0, off.size - 1))) + } + + @Test + fun readBackPayloadMatchesTheReadProbesRequestShape() { + assertTrue( + DeviceConfigWriteGate.readBackPayload() + .contentEquals(DeviceConfigReadProbe.requestBody("enable_raw_data_w_ecg")), + ) + } + + // Multi-character values (max_collection_backlog = "0.0") ---------------------------------------- + + @Test + fun stringValueReadsMultiCharacterValues() { + val r = DeviceConfigReadProbe.ValueResponse(1, echoRecord("max_collection_backlog", "0.0")) + // The single-byte reader loses the rest; the string reader is what a comparison must use. + assertEquals(0x30, r.valueFor("max_collection_backlog")) + assertEquals("0.0", r.stringValueFor("max_collection_backlog")) + } + + @Test + fun stringValueStopsAtNulSoEnvelopePaddingIsNotRead() { + val rec = echoRecord("enable_raw_data_w_ecg", "1") + ByteArray(3) + val r = DeviceConfigReadProbe.ValueResponse(1, rec) + assertEquals("1", r.stringValueFor("enable_raw_data_w_ecg")) + } + + @Test + fun stringValueClaimsNothingWhenThereIsNothingToClaim() { + assertNull( + DeviceConfigReadProbe.ValueResponse(1, echoRecord("enable_rfid", "0")) + .stringValueFor("enable_raw_data_w_ecg"), + ) + assertNull( + DeviceConfigReadProbe.ValueResponse(1, ByteArray(32)).stringValueFor("enable_raw_data_w_ecg"), + ) + assertNull( + DeviceConfigReadProbe.ValueResponse(1, echoRecord("enable_raw_data_w_ecg", "") + ByteArray(1)) + .stringValueFor("enable_raw_data_w_ecg"), + ) + assertNull( + DeviceConfigReadProbe.ValueResponse( + 1, echoRecord("enable_raw_data_w_ecg", "") + byteArrayOf(0xFF.toByte(), 0), + ).stringValueFor("enable_raw_data_w_ecg"), + ) + } + + // The verdict table: the ack never decides, the read-back does ----------------------------------- + + @Test + fun confirmedOnlyWhenTheReadBackReturnsWhatWasAsked() { + val report = EcgRawDataGateReport(true) + assertEquals(EcgRawDataGateReport.Verdict.PENDING, report.verdict) + report.noteWriteAck(1) + // A SUCCESS ack alone must NOT reach a verdict — #891's whole lesson. + assertEquals(EcgRawDataGateReport.Verdict.PENDING, report.verdict) + report.noteReadBack(parseReadBack(readBackFrame(echoRecord("enable_raw_data_w_ecg", "1")))) + assertEquals(EcgRawDataGateReport.Verdict.CONFIRMED, report.verdict) + assertEquals("1", report.storedValue) + assertTrue(report.render().contains("enable_raw_data_w_ecg")) + } + + @Test + fun successAckWithAnUnmovedValueIsUnchangedNotSuccess() { + // The exact failure mode this design exists for: the strap acks SUCCESS and the value did not move. + val report = EcgRawDataGateReport(true) + report.noteWriteAck(1) + report.noteReadBack(parseReadBack(readBackFrame(echoRecord("enable_raw_data_w_ecg", "0")))) + assertEquals(EcgRawDataGateReport.Verdict.UNCHANGED, report.verdict) + assertEquals("0", report.storedValue) + assertTrue(report.summary.contains("did NOT take")) + } + + @Test + fun turningTheGateBackOffConfirmsOnZero() { + val report = EcgRawDataGateReport(false) + assertEquals("0", report.requested) + report.noteReadBack(parseReadBack(readBackFrame(echoRecord("enable_raw_data_w_ecg", "0")))) + assertEquals(EcgRawDataGateReport.Verdict.CONFIRMED, report.verdict) + } + + @Test + fun refusedSilentNotClaimedAndUndecodableAreNeverSuccess() { + val refused = EcgRawDataGateReport(true) + refused.noteReadBack(DeviceConfigReadProbe.ValueResponse(0, ByteArray(0))) + assertEquals(EcgRawDataGateReport.Verdict.REFUSED, refused.verdict) + + val unsupported = EcgRawDataGateReport(true) + unsupported.noteReadBack(DeviceConfigReadProbe.ValueResponse(3, ByteArray(0))) + assertEquals(EcgRawDataGateReport.Verdict.REFUSED, unsupported.verdict) + + val notClaimed = EcgRawDataGateReport(true) + notClaimed.noteReadBack(DeviceConfigReadProbe.ValueResponse(1, echoRecord("enable_rfid", "1"))) + assertEquals(EcgRawDataGateReport.Verdict.NOT_CLAIMED, notClaimed.verdict) + + val silent = EcgRawDataGateReport(true) + silent.noteReadBackTimeout(8) + assertEquals(EcgRawDataGateReport.Verdict.SILENT, silent.verdict) + + val undecodable = EcgRawDataGateReport(true) + undecodable.noteReadBackFailure(DeviceConfigReadProbe.ParseFailure.CRC) + assertEquals(EcgRawDataGateReport.Verdict.UNDECODABLE, undecodable.verdict) + + for (r in listOf(refused, unsupported, notClaimed, silent, undecodable)) { + assertNotEquals(EcgRawDataGateReport.Verdict.CONFIRMED, r.verdict) + assertTrue(r.summary.isNotEmpty()) + } + } + + @Test + fun readBackIsCrcGatedLikeEveryOtherDecode() { + val frame = readBackFrame(echoRecord("enable_raw_data_w_ecg", "1")) + frame[frame.size - 1] = (frame[frame.size - 1].toInt() xor 0xFF).toByte() + val parsed = DeviceConfigReadProbe.parse(frame, DeviceFamily.WHOOP5, 121) + assertNull(parsed.value) + assertEquals(DeviceConfigReadProbe.ParseFailure.CRC, parsed.failure) + } + + @Test + fun renderNamesTheKeyTheVerbsAndTheOpenQuestion() { + val report = EcgRawDataGateReport(true) + report.noteWriteAck(1) + report.noteReadBackTimeout(8) + val text = report.render() + assertTrue(text.contains("SET_DEVICE_CONFIG_VALUE(119)")) + assertTrue(text.contains("GET_DEVICE_CONFIG_VALUE(121)")) + assertTrue(text.contains("SET_FF_VALUE(120) is never sent")) + // The honest framing has to survive into the copyable report a user pastes into an issue. + assertTrue(text.contains("UNKNOWN")) + assertTrue(text.contains("#891")) + } +} 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