From e5287c95dcc3b528de6b4ef1c987fc823226dd8c Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:05:30 +1200 Subject: [PATCH] Make the ECG gate hypothesis testable: an opt-in enable_raw_data_w_ecg write with a mandatory read-back (#891, #103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a WHOOP 5 MG all three TOGGLE_LABRADOR (ECG) commands answer SUCCESS(1) and produce ZERO ECG packets in a 30-second listen (#891). The strap's own 115/116 enumeration lists a device-config key `enable_raw_data_w_ecg`, which reads '0' on that hardware — the leading candidate for the gate. This adds the one experiment that can move #891, and deliberately nothing else. Whether flipping it produces ECG data is UNKNOWN; a confirmed '1' with still no packets is exactly as publishable as a positive. DeviceConfigWriteGate (pure, Swift + Kotlin twins, byte-identical) is the ONE device-config key this app may write beyond the Broadcast-HR flag: - The allowlist is now KEY-AWARE, which is a TIGHTENING. Opcode 119 is shared with Broadcast-HR (#181), so the old opcode-only clause admitted ANY device-config key whenever Broadcast-HR was on. admitsSend parses the key out of the body and admits exactly two keys, each only under its own opt-in — enable_raw_data_w_ecg only on a strap that positively attested itself an MG over DIS (Whoop5Variant.isMG; UNKNOWN is not MG, and a plain 5.0 has no electrodes). The other five enumerated keys are refused unconditionally, and SET_FF_VALUE(120) is refused outright. One pure predicate the send path itself consults, proven by tests over all 256 opcodes. - The write's own COMMAND_RESPONSE is recorded and NOT believed — a SUCCESS byte is not evidence state changed. Every write is followed by GET_DEVICE_CONFIG_VALUE(121) on the same key; only the value that comes back is reported. A SUCCESS ack whose value did not move is reported as `unchanged`, not success. (Framing owed to @ryanbr on #891.) Reversible in one tap — the directions differ only in the value. Opt-in (default OFF) with its own PuffinExperiment key, added to the 5/MG gated set on both platforms. Settings UI on both platforms; new copy ships with its focus- locale translations (Android de/es/fr/pt-rPT/zh + xcstrings). Rebased onto current main (most of #907/#898 already landed via #761/#103): this carries only the net-new ECG write-gate. Adapted to main's single-byte ValueResponse.value(for:) — the gate value is a single ASCII digit, so no change to the shared read probe. ConfigKeySweep dropped (main already has a candidate sweep). Tests: swift test (WhoopProtocol) + Android testFullDebugUnitTest green; compileFullDebugKotlin clean; i18n audit clean. Apple app-target validated via app-build. The write path itself needs on-strap confirmation on a real WHOOP MG. --- .../WhoopProtocol/DeviceConfigWriteGate.swift | 355 ++++++++++++++++ .../DeviceConfigWriteGateTests.swift | 325 +++++++++++++++ Strand/BLE/BLEManager.swift | 121 +++++- Strand/BLE/LiveState.swift | 8 + Strand/BLE/PuffinExperiment.swift | 18 + Strand/Resources/Localizable.xcstrings | 260 ++++++++++++ Strand/Screens/SettingsView.swift | 107 +++++ .../java/com/noop/ble/PuffinExperiment.kt | 22 +- .../main/java/com/noop/ble/WhoopBleClient.kt | 158 +++++++- .../noop/protocol/DeviceConfigWriteGate.kt | 383 ++++++++++++++++++ .../main/java/com/noop/ui/SettingsScreen.kt | 75 ++++ .../app/src/main/res/values-de/strings.xml | 5 + .../app/src/main/res/values-es/strings.xml | 5 + .../app/src/main/res/values-fr/strings.xml | 5 + .../src/main/res/values-pt-rPT/strings.xml | 5 + .../app/src/main/res/values-zh/strings.xml | 5 + android/app/src/main/res/values/strings.xml | 5 + .../protocol/DeviceConfigWriteGateTest.kt | 321 +++++++++++++++ 18 files changed, 2175 insertions(+), 8 deletions(-) create mode 100644 Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigWriteGate.swift create mode 100644 Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigWriteGateTests.swift create mode 100644 android/app/src/main/java/com/noop/protocol/DeviceConfigWriteGate.kt create mode 100644 android/app/src/test/java/com/noop/protocol/DeviceConfigWriteGateTest.kt diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigWriteGate.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigWriteGate.swift new file mode 100644 index 0000000000..4e37a763ef --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigWriteGate.swift @@ -0,0 +1,355 @@ +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 + // The gate stores a single ASCII digit ('0'/'1'), so the one byte after the echoed name field is + // the whole value — read it with the shared read-probe's single-byte accessor. (Multi-character + // device-config values like max_collection_backlog="0.0" belong to the read probe, not this gate.) + let stored = r.value(for: DeviceConfigWriteGate.ecgRawDataKey) + .flatMap { (0x20...0x7E).contains($0) ? String(UnicodeScalar($0)) : nil } + storedValue = stored + var line = "GET_DEVICE_CONFIG_VALUE(121) key=\"\(DeviceConfigWriteGate.ecgRawDataKey)\"" + if let c = r.resultCode { line += " → result=\(FeatureFlagProbe.resultLabel(c))(\(c))" } else { line += " →" } + if let stored { line += " value='\(stored)'" } + line += " record=[\(r.recordHex)]" + trace.append(line) + + if r.isUnsupported || r.isFailure { + verdict = .refused + } else if let stored { + verdict = stored == requested ? .confirmed : .unchanged + } else { + verdict = .notClaimed + } + } + + /// Record a read-back reply that could not be decoded. + public mutating func noteReadBackFailure(_ f: DeviceConfigReadProbe.ParseFailure) { + let why: String + switch f { + case .crc: why = "CRC failed — frame rejected (never decoded)" + case .envelope: why = "not a COMMAND_RESPONSE envelope" + case .wrongCommand: why = "COMMAND_RESPONSE for a different command" + case .truncated: why = "record too short to hold a response" + } + trace.append("read-back reply not decoded: \(why)") + verdict = .undecodable + } + + /// Record the strap answering nothing at all to the read-back. + public mutating func noteReadBackTimeout(seconds: Int) { + trace.append("GET_DEVICE_CONFIG_VALUE(121) → no COMMAND_RESPONSE within \(seconds)s") + verdict = .silent + } + + /// One-line summary, suitable for a Settings row. + public var summary: String { + switch verdict { + case .confirmed: + return "Strap now reports \(DeviceConfigWriteGate.ecgRawDataKey)='\(requested)' (read back, not just acked)." + case .unchanged: + return "Write did NOT take: asked for '\(requested)', strap still reports '\(storedValue ?? "?")'." + case .notClaimed: + return "Strap answered the read-back but did not echo the key, so no value is claimed." + case .refused: + return "Strap refused the read-back for this key — the stored value is unknown." + case .silent: + return "No reply to the read-back — the stored value is unknown." + case .undecodable: + return "The read-back reply did not decode — the stored value is unknown." + case .pending: + return "Waiting for the read-back…" + } + } + + /// The full copyable report. + public func render() -> String { + var sb = "#891 ECG RAW-DATA GATE — WHOOP MG\n" + sb += "Key: \(DeviceConfigWriteGate.ecgRawDataKey) (the strap's own 115/116 enumeration listed it)\n" + sb += "Wrote '\(requested)' via SET_DEVICE_CONFIG_VALUE(119), then read it back with " + sb += "GET_DEVICE_CONFIG_VALUE(121). SET_FF_VALUE(120) is never sent from this path, and no other " + sb += "device-config key is writable from it.\n" + sb += "\nVerdict: \(verdict.rawValue) — \(summary)\n" + sb += "\nExchange:\n" + for line in trace { sb += " " + line + "\n" } + sb += "\nWhether this gate actually produces ECG data is UNKNOWN. If it now reads '1' and a " + sb += "TOGGLE_LABRADOR listen still yields zero packets, that is a real result for #891 — please " + sb += "share this report there either way.\n" + return sb + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigWriteGateTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigWriteGateTests.swift new file mode 100644 index 0000000000..fc43047b93 --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigWriteGateTests.swift @@ -0,0 +1,325 @@ +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: - The single-digit value the read-back reads + + func testValueReadsTheSingleDigitGateValue() { + // The gate stores one ASCII digit, so the report reads exactly the byte after the echoed name + // field (main's single-byte `value(for:)`). Multi-character values belong to the read probe. + let one = DeviceConfigReadProbe.ValueResponse( + resultCode: 1, record: echoRecord(key: "enable_raw_data_w_ecg", value: "1")) + XCTAssertEqual(one.value(for: "enable_raw_data_w_ecg"), 0x31) + // Trailing envelope NUL padding after the value byte is not part of it. + let padded = DeviceConfigReadProbe.ValueResponse( + resultCode: 1, record: echoRecord(key: "enable_raw_data_w_ecg", value: "0") + [0, 0, 0]) + XCTAssertEqual(padded.value(for: "enable_raw_data_w_ecg"), 0x30) + // A record that stops at the name field claims no value. + let bare = DeviceConfigReadProbe.ValueResponse( + resultCode: 1, record: [UInt8](repeating: 0, count: 32)) + XCTAssertNil(bare.value(for: "enable_raw_data_w_ecg")) + // The key not being echoed at all claims nothing either. + let other = DeviceConfigReadProbe.ValueResponse( + resultCode: 1, record: echoRecord(key: "enable_rfid", value: "0")) + XCTAssertNil(other.value(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/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 9979c190c9..1d3f8becb5 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1647,10 +1647,20 @@ public final class BLEManager: NSObject, ObservableObject { // in flight, exactly like the read probes' 121/128 clause above, so a default install can // never form these bytes. The write ack is not trusted; this is what proves the clear. || (FeatureFlagWriteGate.isReadBackOpcode(command.rawValue) && r22DisableRun != nil) - // SET_DEVICE_CONFIG (the Broadcast-HR flag) is allowed ONLY while that opt-in is on — - // it writes one persistent device-config value so the strap advertises standard HR. - // Reversible; driven only by setBroadcastHr(_:). (#181) - || (command == .setDeviceConfig && PuffinExperiment.broadcastHrEnabled) + // SET_DEVICE_CONFIG (119) is KEY-AWARE now (#891): the opcode is shared between the + // Broadcast-HR flag (#181) and the ECG raw-data gate, so an opcode-only clause would admit + // ANY device-config key whenever EITHER opt-in happened to be on. `admitsSend` parses the + // key out of the body and admits exactly the two named keys, each only under its own opt-in + // — and the ECG key additionally only on an attested MG. Every other enumerated key, and + // SET_FF_VALUE(120), is refused. This is a tightening of the old broadcast-HR-only clause. + || DeviceConfigWriteGate.admitsSend(opcode: command.rawValue, + payload: payload, + ecgGateOptIn: PuffinExperiment.ecgRawDataEnabled, + isMG: isWhoop5MG, + broadcastHrOptIn: PuffinExperiment.broadcastHrEnabled) + // GET_DEVICE_CONFIG_VALUE(121) as the ECG gate's mandatory READ-BACK — gated on a write + // being verified, exactly like the R22 read-back above. The write ack is never the proof. + || (DeviceConfigWriteGate.isReadBackOpcode(command.rawValue) && ecgGateReport != nil) // The WHOOP MG ECG ("Labrador") family — allowed ONLY when BOTH gates hold: the // Experimental opt-in is on AND the strap has POSITIVELY attested itself an MG over the // Device Information Service. A plain 5.0 has no electrodes and an unidentified strap @@ -2735,6 +2745,91 @@ public final class BLEManager: NSObject, ObservableObject { log("Broadcast HR: wrote whoop_live_hr_in_adv_ind_pkt=\(on ? "1" : "0")") } + // MARK: - ECG raw-data gate (#891) — an opt-in write with a MANDATORY read-back + + private static let ecgGateReadBackTimeout: TimeInterval = 8 + + /// Non-nil only while a write is being verified. The send allowlist consults it so the 121 read-back + /// can go out, and the frame router consults it to route the ack + read-back replies here. + private var ecgGateReport: EcgRawDataGateReport? + private var ecgGateStep = 0 + + /// Write `enable_raw_data_w_ecg`='1'/'0' on an attested MG, then read it back — the ack is NOT the + /// result (#891). Preconditions match the gate: the experiment opt-in, an MG-attested strap, a full + /// bond. Reversible in one tap; the two directions differ only in the value byte. + 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 { + 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 + } + // The full encrypted bond, not the live-HR-only link — a config write over the latter silently + // fails (#269). Matches the R22 write paths and the button's own `ecgGateReady` gate in Settings. + 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 and pair the strap 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) + + // Settle before the read-back, the same spacing the R22 sequence uses; then bound the whole + // verification with a single read-back window. `ecgGateStep` fences a stale timer from a prior run. + 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() + } + } + } + + 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 } + + /// The write's own COMMAND_RESPONSE — recorded, never treated as proof (#891). Routed here from the + /// frame router when a 119 reply lands while a write is being verified. + private func handleEcgGateWriteAck(_ frame: [UInt8], cmdOff: Int) { + guard ecgGateReport != nil else { return } + let resultIndex = cmdOff + 2 + let code: Int? = frame.count > resultIndex ? Int(frame[resultIndex]) : nil + ecgGateReport?.noteWriteAck(resultCode: code) + state.ecgRawDataGate = ecgGateReport + } + + /// The 121 read-back — the ONLY thing that decides the verdict. Routed here from the frame router. + 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 @@ -4443,6 +4538,13 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // probes above it has already written to the strap, so the user must be told exactly how far it got // and which keys are still set. This publishes the partial report and re-closes the 128 allowlist. abandonR22DisableRun("the strap disconnected mid-run") + // #891: an ECG-gate write interrupted mid-verification is RENDERED, not dropped — it has already + // written to the strap, so the user is told the read-back never landed (verdict silent). Clearing + // the in-flight tracker re-closes the 121 read-back allowlist and makes any pending timer no-op. + if ecgGateReport != nil { + ecgGateReport?.noteReadBackTimeout(seconds: Int(BLEManager.ecgGateReadBackTimeout)) + finishEcgGateWrite() + } 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 @@ -5373,6 +5475,17 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { || frame[10] == WhoopCommand.getFeatureFlagValue.rawValue { handleR22DisableResponse(frame, isWhoop5: true) } + // #891: a reply belonging to an ECG raw-data gate write — either the SET_DEVICE_CONFIG(119) + // write ack (recorded, never the proof) or the GET_DEVICE_CONFIG_VALUE(121) read-back that + // decides the verdict. Both handlers guard on ecgGateReport being live, so they no-op + // outside a verification (and the 121 read-probe clause above no-ops too, its run not live). + if frame.count > 10, frame[8] == 0x24 { + if frame[10] == WhoopCommand.setDeviceConfig.rawValue { + handleEcgGateWriteAck(frame, cmdOff: 10) + } else if frame[10] == WhoopCommand.getDeviceConfigValue.rawValue { + handleEcgGateReadBack(frame, isWhoop5: true) + } + } // #695: a 5/MG GET_DATA_RANGE COMMAND_RESPONSE (puffin envelope: type @8, cmd @10). Feeds // the SAME newest/oldest window + backfill gate + diagnostics as the 4.0 path above — this // reply was previously ignored on 5/MG (the 4.0 handler keyed on frame[6]). cmdOff = 10. diff --git a/Strand/BLE/LiveState.swift b/Strand/BLE/LiveState.swift index d47700bac1..ec1118253a 100644 --- a/Strand/BLE/LiveState.swift +++ b/Strand/BLE/LiveState.swift @@ -276,6 +276,14 @@ public final class LiveState: ObservableObject { /// Android WhoopBleClient.r22DisableReport flow. @Published public var r22DisableReport: 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. Like the R22 disable report (and unlike the read-only probes), a + /// write interrupted mid-verification by a disconnect is RENDERED here rather than dropped — it has + /// already written to the strap — and a completed result persists until the next write or + /// `clearEcgRawDataGate()`. Twin of the Android WhoopBleClient.ecgRawDataGate flow. + @Published public var ecgRawDataGate: EcgRawDataGateReport? = nil + /// Wrist-wear state from WRIST_ON/WRIST_OFF events. Defaults true so wear-gated features work /// before the first event arrives; flipped by FrameRouter on a real event. @Published public var worn: Bool = true diff --git a/Strand/BLE/PuffinExperiment.swift b/Strand/BLE/PuffinExperiment.swift index 02592f50c0..beb44ef2d3 100644 --- a/Strand/BLE/PuffinExperiment.swift +++ b/Strand/BLE/PuffinExperiment.swift @@ -35,6 +35,23 @@ 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: this repo gives every PERSISTENT STRAP WRITE its own + /// deliberate opt-in (`deepDataKey` #174, `broadcastHrKey` #181), so reusing one switch for "listen for + /// ECG packets" (`ecgKey`) 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 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; @@ -229,6 +246,7 @@ enum PuffinExperiment { // takes the ECG decoder but has no ECG app layer, so Kotlin's FIVE_MG_GATED_KEYS has no twin // entry to add. Add one there in the same change that adds an Android probe. ecgKey, + 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 1b0d42fa9a..73b788cb53 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -174036,6 +174036,266 @@ } } } + }, + "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)" + } + } + } + }, + "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 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')" + } + } + } + }, + "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 的有用答案。" + } + } + } + }, + "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'。全程只寫入這一個鍵;你手環列出的另外六個從不觸碰。" + } + } + } } }, "version": "1.0" diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 7e6125690b..9fe14b1d91 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -51,6 +51,62 @@ 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 + + /// True when the connected strap has positively attested itself a WHOOP MG. The variant is published as + /// its label string (`LiveState.whoop5Variant`); "MG" is `Whoop5Variant.mg.label`. nil / not-yet- + /// identified / a plain 5.0 is not an MG. + private var ecgVariantIsMG: Bool { live.whoop5Variant == Whoop5Variant.mg.label } + + /// 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 && ecgVariantIsMG + #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 !ecgVariantIsMG { + // A nil / non-MG variant 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 + } + } + /// WHOOP MG ECG ("Labrador") experiment. Unlocks the gated, user-initiated ECG probe on the Devices /// card. Default off; with it off the four ECG opcodes are dropped by the command allowlist, so no /// ECG byte can reach a strap. See [PuffinExperiment.ecgKey]. @@ -1634,6 +1690,57 @@ struct SettingsView: View { .accessibilityElement(children: .combine) } + // MARK: #891 ECG raw-data gate — the second device-config key this app may write, MG-only. + Divider().overlay(StrandPalette.hairline) + + 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) + } + } + Divider().overlay(StrandPalette.hairline) // MARK: WHOOP MG ECG (Labrador) — MG-only, writes ECG control commands. NOT medical. 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..528bb4d2e7 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,21 @@ 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, 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 ([deepData] #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 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 +146,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 d895c750fa..9e6e6d957b 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -42,6 +42,8 @@ import com.noop.protocol.R22DisableReport 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 @@ -3063,9 +3065,22 @@ class WhoopBleClient( // in flight, exactly like the read probes' 121/128 clause above, so a default install can // never form these bytes. The write ack is not trusted; this is what proves the clear. !(FeatureFlagWriteGate.isReadBackOpcode(cmd.rawValue) && r22DisableRun != null) && - // SET_DEVICE_CONFIG (the Broadcast-HR flag) is allowed ONLY while that opt-in is on. - // Reversible; driven only by setBroadcastHr(). (#181) - !(cmd == CommandNumber.SET_DEVICE_CONFIG && puffinExperiment.broadcastHr)) { + // SET_DEVICE_CONFIG (119) is KEY-AWARE now (#891): the opcode is shared between the + // Broadcast-HR flag (#181) and the ECG raw-data gate, so an opcode-only clause would admit + // ANY device-config key whenever EITHER opt-in was on. admitsSend parses the key out of the + // body and admits exactly the two named keys, each only under its own opt-in — the ECG key + // additionally only on an attested MG. Every other enumerated key and SET_FF_VALUE(120) is + // refused; a tightening of the old broadcast-HR-only clause. + !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 READ-BACK, gated on a write being + // verified — same discipline as the R22 read-back above. The write ack is never the proof. + !(DeviceConfigWriteGate.isReadBackOpcode(cmd.rawValue) && ecgGateReport != null)) { log("send(${cmd.name}) skipped — no WHOOP 5/MG framing for this command yet") return } @@ -3826,6 +3841,7 @@ class WhoopBleClient( */ private fun noteWhoop5VariantFromDis() { val variant = Whoop5Variant.from(disSerial, disHwRev) + _whoop5Variant.value = variant // #520/#891: publish so MG-only UI can gate on it val prefix = disSerial?.trim()?.uppercase()?.take(3) ?: "?" log("DIS: serialPrefix=$prefix hwRev=${disHwRev ?: "?"} -> variant=${variant.label}") } @@ -5142,6 +5158,19 @@ class WhoopBleClient( ) { handleR22DisableResponse(frame) } + // #891: the ECG gate's own two replies — the SET_DEVICE_CONFIG(119) write ack (recorded, + // never the proof) and the GET_DEVICE_CONFIG_VALUE(121) read-back that decides the + // verdict. Both handlers guard on ecgGateReport being live, so these are byte compares on + // every other frame; 121 is also matched by the read-probe clause above, but the two + // paths guard on DIFFERENT in-flight sentinels, so exactly one 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 @@ -5957,6 +5986,119 @@ class WhoopBleClient( log("Broadcast HR: wrote whoop_live_hr_in_adv_ind_pkt=" + (if (on) "1" else "0")) } + // ---- ECG raw-data gate (#891) — an opt-in write with a MANDATORY read-back ---- + + /** Settle before the read-back (the spacing the R22 sequence uses) and the read-back reply window. */ + private val ecgGateSettleMs = 200L + private val ecgGateReadBackTimeoutMs = 8_000L + + /** The connected strap's attested 5-generation hardware variant, re-derived from the DIS strings this + * connection read rather than cached — 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); 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) + + private val _whoop5Variant = MutableStateFlow(Whoop5Variant.UNKNOWN) + /** #520/#891: the attested variant as observable state, for UI that gates an MG-only action. Set from + * DIS in [noteWhoop5VariantFromDis], reset to UNKNOWN on disconnect. */ + val whoop5VariantFlow: StateFlow = _whoop5Variant.asStateFlow() + + private val _ecgRawDataGate = MutableStateFlow(null) + /** #891: the last `enable_raw_data_w_ecg` write result, AFTER its mandatory GET_DEVICE_CONFIG_VALUE(121) + * read-back — the write ack is never reported as the outcome. Twin of Swift LiveState.ecgRawDataGate. */ + val ecgRawDataGate: StateFlow = _ecgRawDataGate.asStateFlow() + + /** Non-null only while a write is being verified: the send allowlist consults it so the 121 read-back + * can go out, and the frame router routes the ack + read-back replies here. */ + private var ecgGateReport: EcgRawDataGateReport? = null + private var ecgGateStep = 0 + + /** EXPERIMENTAL (#891): write `enable_raw_data_w_ecg`='1'/'0' on an attested MG, then read it back and + * report what the strap actually stores — the ack is NOT the result. Gates: opt-in on, MG-attested, + * connected + bonded. Not wear-gated (it stores a value, it does not start a stream). Reversible in one + * call with on=false. Mirrors `BLEManager.setEcgRawDataGate`. */ + 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) { + 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) + + // Settle before the read-back, then bound the whole verification with a single read-back window. + // `ecgGateStep` fences a stale timer from a prior run. + 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((ecgGateReadBackTimeoutMs / 1000).toInt()) + finishEcgGateWrite() + }, ecgGateReadBackTimeoutMs) + }, ecgGateSettleMs) + } + + 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 } + + /** The write's own COMMAND_RESPONSE — recorded, never the proof (#891). The puffin envelope puts the + * type at cmdOff-2 and the cmd at cmdOff, so the result byte is at cmdOff+2. */ + private fun handleEcgGateWriteAck(frame: ByteArray, cmdOff: Int) { + if (ecgGateReport == null) return + val resultIndex = cmdOff + 2 + val code = if (frame.size > resultIndex) frame[resultIndex].toInt() and 0xFF else null + ecgGateReport?.noteWriteAck(code) + _ecgRawDataGate.value = ecgGateReport + } + + /** The 121 read-back — the ONLY thing that decides the verdict. */ + 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, CommandNumber.GET_DEVICE_CONFIG_VALUE.rawValue) + val value = parsed.value + if (value != null) ecgGateReport?.noteReadBack(value) + else 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 @@ -7346,6 +7488,16 @@ class WhoopBleClient( abandonR22DisableRun("the strap disconnected mid-run") deviceConfigReport = null deviceConfigAwaiting = null + // #891: an ECG-gate write interrupted mid-verification is RENDERED, not dropped — it has already + // written to the strap, so the user is told the read-back never landed. Clearing the tracker + // re-closes the 121 read-back allowlist and makes any pending timer no-op. + if (ecgGateReport != null) { + ecgGateReport?.noteReadBackTimeout((ecgGateReadBackTimeoutMs / 1000).toInt()) + finishEcgGateWrite() + } + // #520/#891: the DIS strings belong to the link that just dropped; a stale variant must not keep an + // MG-only capability unlocked for whatever connects next. + _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/DeviceConfigWriteGate.kt b/android/app/src/main/java/com/noop/protocol/DeviceConfigWriteGate.kt new file mode 100644 index 0000000000..0ab92d9134 --- /dev/null +++ b/android/app/src/main/java/com/noop/protocol/DeviceConfigWriteGate.kt @@ -0,0 +1,383 @@ +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 + // The gate stores a single ASCII digit ('0'/'1'), so the one byte after the echoed name field is + // the whole value — read it with the shared read-probe's single-byte accessor. (Multi-character + // device-config values like max_collection_backlog="0.0" belong to the read probe, not this gate.) + val stored = r.valueFor(DeviceConfigWriteGate.ECG_RAW_DATA_KEY) + ?.takeIf { it in 0x20..0x7E }?.toChar()?.toString() + storedValue = stored + val sb = StringBuilder( + "GET_DEVICE_CONFIG_VALUE(121) key=\"${DeviceConfigWriteGate.ECG_RAW_DATA_KEY}\"", + ) + val c = r.resultCode + if (c != null) sb.append(" → result=${FeatureFlagProbe.resultLabel(c)}($c)") else sb.append(" →") + if (stored != null) sb.append(" value='$stored'") + sb.append(" record=[${r.recordHex}]") + _trace.add(sb.toString()) + + verdict = when { + r.isUnsupported || r.isFailure -> Verdict.REFUSED + stored == null -> Verdict.NOT_CLAIMED + stored == requested -> Verdict.CONFIRMED + else -> Verdict.UNCHANGED + } + } + + /** Record a read-back reply that could not be decoded. */ + fun noteReadBackFailure(f: DeviceConfigReadProbe.ParseFailure) { + val why = when (f) { + DeviceConfigReadProbe.ParseFailure.CRC -> "CRC failed — frame rejected (never decoded)" + DeviceConfigReadProbe.ParseFailure.ENVELOPE -> "not a COMMAND_RESPONSE envelope" + DeviceConfigReadProbe.ParseFailure.WRONG_COMMAND -> "COMMAND_RESPONSE for a different command" + DeviceConfigReadProbe.ParseFailure.TRUNCATED -> "record too short to hold a response" + } + _trace.add("read-back reply not decoded: $why") + verdict = Verdict.UNDECODABLE + } + + /** Record the strap answering nothing at all to the read-back. */ + fun noteReadBackTimeout(seconds: Int) { + _trace.add("GET_DEVICE_CONFIG_VALUE(121) → no COMMAND_RESPONSE within ${seconds}s") + verdict = Verdict.SILENT + } + + /** One-line summary, suitable for a Settings row. */ + val summary: String + get() = when (verdict) { + Verdict.CONFIRMED -> + "Strap now reports ${DeviceConfigWriteGate.ECG_RAW_DATA_KEY}='$requested' " + + "(read back, not just acked)." + Verdict.UNCHANGED -> + "Write did NOT take: asked for '$requested', strap still reports '${storedValue ?: "?"}'." + Verdict.NOT_CLAIMED -> + "Strap answered the read-back but did not echo the key, so no value is claimed." + Verdict.REFUSED -> + "Strap refused the read-back for this key — the stored value is unknown." + Verdict.SILENT -> "No reply to the read-back — the stored value is unknown." + Verdict.UNDECODABLE -> + "The read-back reply did not decode — the stored value is unknown." + Verdict.PENDING -> "Waiting for the read-back…" + } + + /** The full copyable report. */ + fun render(): String { + val sb = StringBuilder() + sb.append("#891 ECG RAW-DATA GATE — WHOOP MG\n") + sb.append("Key: ${DeviceConfigWriteGate.ECG_RAW_DATA_KEY} ") + sb.append("(the strap's own 115/116 enumeration listed it)\n") + sb.append("Wrote '$requested' via SET_DEVICE_CONFIG_VALUE(119), then read it back with ") + sb.append("GET_DEVICE_CONFIG_VALUE(121). SET_FF_VALUE(120) is never sent from this path, and no ") + sb.append("other device-config key is writable from it.\n") + sb.append("\nVerdict: ${verdict.label} — $summary\n") + sb.append("\nExchange:\n") + for (line in _trace) sb.append(" ").append(line).append("\n") + sb.append("\nWhether this gate actually produces ECG data is UNKNOWN. If it now reads '1' and a ") + sb.append("TOGGLE_LABRADOR listen still yields zero packets, that is a real result for #891 — ") + sb.append("please share this report there either way.\n") + return sb.toString() + } +} diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 25c51a509a..41dddfb8c5 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -106,6 +106,7 @@ import com.noop.ble.WhoopBleClient // #174: the R22 card reads the flag COUNT off Whoop5Config.enableR22Sequence rather than restating it — // the hardcoded "15" outlived the sequence growing to 16 and declared success a flag early. import com.noop.protocol.Whoop5Config +import com.noop.protocol.EcgRawDataGateReport import com.noop.ble.WhoopModel import com.noop.data.DataBackup import com.noop.ingest.RawSensorExport @@ -490,6 +491,11 @@ fun SettingsScreen( // the card cannot drift from it again — it said "15" for the whole life of the 16-flag sequence. val r22FlagCount = Whoop5Config.enableR22Sequence.size var broadcastHr by remember(rev) { mutableStateOf(puffinExperiment.broadcastHr) } + // ECG raw-data gate (#891): the opt-in, the write result, and the attested-MG gate the buttons need. + var ecgRawData by remember(rev) { mutableStateOf(puffinExperiment.ecgRawData) } + 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) } @@ -1934,6 +1940,75 @@ fun SettingsScreen( color = Palette.textTertiary, ) + // --- ECG raw-data gate — an opt-in device-config WRITE with a mandatory read-back. (#891) --- + 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, + 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) }, + ) + } + 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 f6dd804f6b..015ebd831b 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1911,4 +1911,9 @@ Live-Session-Hinweise Trainingsstart und -ende Timer & Wecker + ECG-Rohdatensperre (nur WHOOP MG) + Dein Band hat seine eigenen Geräte-Konfigurationsschlüssel aufgelistet, darunter enable_raw_data_w_ecg. Auf einem MG ohne ECG-Abo steht er auf \'0\' — während alle drei ECG-Befehle mit SUCCESS antworten und trotzdem keine Daten senden (#891). Das ist die naheliegendste Vermutung, was ECG verschlossen hält. Ob das Umlegen tatsächlich ECG-Daten liefert, weiß niemand: genau das herauszufinden ist der Zweck, und „immer noch nichts“ ist ein brauchbares Ergebnis, das sich zu posten lohnt (#891). + Dies schreibt eine Einstellung, die AUF DEINEM BAND BLEIBT, bis du sie zurücksetzt — es ist keine App-Einstellung, und das Schließen von NOOP macht sie nicht rückgängig. „Sperre ausschalten“ schreibt mit einem Tippen wieder \'0\'. Es wird ausschließlich dieser eine Schlüssel geschrieben; die anderen sechs, die dein Band aufgelistet hat, werden nie angefasst. + Sperre einschalten (\'1\' schreiben) + Sperre ausschalten (\'0\' schreiben) diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index cc4cbfa3d3..6740b7c032 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1896,4 +1896,9 @@ Avisos de sesión en directo Inicio y fin del entrenamiento Temporizador y alarma + Bloqueo de datos ECG sin procesar (solo WHOOP MG) + Tu correa ha enumerado sus propias claves de configuración, y una de ellas es enable_raw_data_w_ecg. En un MG sin suscripción de ECG vale \'0\', mientras que los tres comandos de ECG responden SUCCESS y no envían dato alguno (#891). Es la hipótesis principal sobre qué mantiene el ECG cerrado. Nadie sabe si cambiarla produce realmente datos de ECG: averiguarlo es el objetivo, y «sigue sin haber nada» es una respuesta útil que merece publicarse en #891. + Esto escribe un ajuste que PERMANECE EN TU CORREA hasta que lo cambies de vuelta: no es una preferencia de la app y cerrar NOOP no lo deshace. «Desactivar bloqueo» vuelve a escribir \'0\' con un solo toque. Solo se escribe esta clave; las otras seis que enumeró tu correa nunca se tocan. + Activar bloqueo (escribir \'1\') + Desactivar bloqueo (escribir \'0\') diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index c8b4b281e8..a7c1d26580 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1896,4 +1896,9 @@ Signaux de séance en direct Début et fin d’entraînement Minuteur et alarme + Verrou des données ECG brutes (WHOOP MG uniquement) + Votre bracelet a énuméré ses propres clés de configuration, dont enable_raw_data_w_ecg. Sur un MG sans abonnement ECG, elle vaut « 0 » — alors que les trois commandes ECG répondent SUCCESS sans envoyer la moindre donnée (#891). C\'est l\'hypothèse principale sur ce qui maintient l\'ECG fermé. Personne ne sait si la basculer produit réellement des données ECG : le savoir est justement le but, et « toujours rien » est une réponse utile à publier sur #891. + Ceci écrit un réglage qui RESTE SUR VOTRE BRACELET jusqu\'à ce que vous le remettiez — ce n\'est pas une préférence de l\'app, et fermer NOOP ne l\'annule pas. « Désactiver le verrou » réécrit « 0 » en une seule touche. Seule cette clé est écrite ; les six autres que votre bracelet a énumérées ne sont jamais touchées. + Activer le verrou (écrire « 1 ») + Désactiver le verrou (écrire « 0 ») diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index fffb3d61c2..fdf333fd8f 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -1890,4 +1890,9 @@ Sinais de sessão em direto Início e fim do treino Temporizador e alarme + Bloqueio de dados ECG em bruto (apenas WHOOP MG) + A sua pulseira listou as suas próprias chaves de configuração, e uma delas é enable_raw_data_w_ecg. Num MG sem subscrição de ECG lê \'0\' — enquanto os três comandos de ECG respondem SUCCESS e não enviam dados nenhuns (#891). Esta é a principal hipótese para o que mantém o ECG fechado. Ninguém sabe se mudá-la produz mesmo dados de ECG: descobrir é o objetivo, e «continua sem nada» é uma resposta útil que vale a pena publicar em #891. + Isto escreve uma definição que FICA NA SUA PULSEIRA até a repor — não é uma preferência da aplicação e fechar o NOOP não a desfaz. «Desativar bloqueio» volta a escrever \'0\' com um só toque. Só esta chave é escrita; as outras seis que a sua pulseira listou nunca são tocadas. + Ativar bloqueio (escrever \'1\') + Desativar bloqueio (escrever \'0\') diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 2ccc3ab934..bbc5b6d717 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1824,4 +1824,9 @@ 实时会话提示 锻炼开始和结束 计时器和闹钟 + ECG 原始数据开关(仅限 WHOOP MG) + 你的手环列出了它自己的设备配置键,其中之一是 enable_raw_data_w_ecg。在没有 ECG 订阅的 MG 上它读作 \'0\',而三条 ECG 命令都回应 SUCCESS 却完全不发送数据 (#891)。这是目前对「什么在挡住 ECG」最有力的猜测。翻转它是否真能产生 ECG 数据,没有人知道:弄清楚正是目的,而「仍然没有」同样是值得发到 #891 的有用答案。 + 这会写入一项会一直留在你手环上的设置,直到你改回来——它不是应用内的偏好设置,关闭 NOOP 也不会撤销。「关闭开关」一键即可再写入 \'0\'。全程只写入这一个键;你手环列出的另外六个从不触碰。 + 打开开关(写入 \'1\') + 关闭开关(写入 \'0\') diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a5990ca922..43ab445e48 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1922,4 +1922,9 @@ Live Session cues Workout start & end Timer & alarm + ECG raw-data gate (WHOOP MG only) + Your strap listed its own device-config keys, and one of them is enable_raw_data_w_ecg. On an MG with no ECG subscription it reads \'0\' — while all three ECG commands answer SUCCESS and send no data at all (#891). This is the leading guess for what\'s holding ECG shut. Nobody knows whether flipping it actually produces ECG data: finding out is the point, and \"still nothing\" is a useful answer worth posting to #891. + This writes a setting that STAYS ON YOUR STRAP until you change it back — it isn\'t an app preference, and closing NOOP won\'t undo it. \"Turn gate off\" writes \'0\' again, in one tap. Only this one key is ever written; the other six your strap listed are never touched. + Turn gate on (write \'1\') + Turn gate off (write \'0\') diff --git a/android/app/src/test/java/com/noop/protocol/DeviceConfigWriteGateTest.kt b/android/app/src/test/java/com/noop/protocol/DeviceConfigWriteGateTest.kt new file mode 100644 index 0000000000..da99c3cf4f --- /dev/null +++ b/android/app/src/test/java/com/noop/protocol/DeviceConfigWriteGateTest.kt @@ -0,0 +1,321 @@ +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")), + ) + } + + // The single-digit value the read-back reads ----------------------------------------------------- + + @Test + fun valueReadsTheSingleDigitGateValue() { + // The gate stores one ASCII digit, so the report reads exactly the byte after the echoed name + // field (main's single-byte valueFor). Multi-character values belong to the read probe. + val one = DeviceConfigReadProbe.ValueResponse(1, echoRecord("enable_raw_data_w_ecg", "1")) + assertEquals(0x31, one.valueFor("enable_raw_data_w_ecg")) + // Trailing envelope NUL padding after the value byte is not part of it. + val padded = DeviceConfigReadProbe.ValueResponse(1, echoRecord("enable_raw_data_w_ecg", "0") + ByteArray(3)) + assertEquals(0x30, padded.valueFor("enable_raw_data_w_ecg")) + // A record that stops at the name field claims no value. + assertNull(DeviceConfigReadProbe.ValueResponse(1, ByteArray(32)).valueFor("enable_raw_data_w_ecg")) + // The key not being echoed at all claims nothing either. + assertNull( + DeviceConfigReadProbe.ValueResponse(1, echoRecord("enable_rfid", "0")) + .valueFor("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")) + } +}