From 6cf7fe4f43ea2cf6f9777c23f525329cc981d18d Mon Sep 17 00:00:00 2001 From: Benjamin Woolston Date: Thu, 10 Sep 2026 22:01:21 +1000 Subject: [PATCH 1/2] fix: do not report a failed WHOOP 5 flag read as a value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DeviceConfigReadProbe` built its report from `r.value(for:)` regardless of the reply's result code. A FAILURE reply echoes the requested key back with zero padding, so a rejected read was rendered as a stored value of 0 — indistinguishable from a key that genuinely holds 0. Take a value only when the reply reported success (or carried no result code), and say plainly in the report when verbs answered but no reply succeeded, rather than presenting a fabricated zero. Also read four additional WHOOP 5 flag names returned by a complete 117/118 enumeration on firmware 50.41.1.0: enable_r22_v9_packets, enable_frizzle_burst_mode, ir_1x_enable and enable_rocky2. All four answered CRC-valid SUCCESS with ASCII '2' across 29 replies (21 successful, eight rejected guessed keys), with no timeout or reconnect. Their effects and accepted values are unknown, so they are read-only additions: the R22 write sequence is untouched, and the report title distinguishes enumerated names from names NOOP itself writes. Scoped to macOS, where the hardware evidence was taken; iOS and Android keep their existing probe plans and decoding. --- .../WhoopProtocol/DeviceConfigReadProbe.swift | 38 ++++++++++++- .../DeviceConfigReadProbeTests.swift | 54 +++++++++++++++++++ Strand/BLE/BLEManager.swift | 11 +++- 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift index a238e2a82c..3553019be9 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift @@ -115,6 +115,25 @@ public enum DeviceConfigReadProbe { /// about the key. public static let deviceConfigDiscoveryKey = "whoop_live_hr_in_adv_ind_pkt" + /// Names to read, independent of the flags NOOP writes. The four extra WHOOP 5 keys were + /// returned by a complete 117/118 exchange on firmware 50.41.1.0. Their effects and accepted + /// values are unknown; observing a name does not authorize adding it to the enable sequence. + /// This macOS hardware investigation intentionally leaves mobile probe plans and decoding unchanged: + /// the requested scope and the available transport evidence are macOS plus this WHOOP 5 firmware. + /// Hardware check, 2026-09-10: all four GET_FF_VALUE replies were CRC-valid SUCCESS with ASCII + /// '2'. The full probe received 29 replies (21 successful, eight rejected guessed keys), with + /// no timeout or reconnect. + public static func knownFlagKeys(for family: DeviceFamily) -> [String] { + let existing = Whoop5Config.enableR22Sequence.map(\.name) + guard family == .whoop5 else { return existing } + return existing + [ + "enable_r22_v9_packets", + "enable_frizzle_burst_mode", + "ir_1x_enable", + "enable_rocky2", + ] + } + /// **GUESSES.** Candidate oxygen-related key names to try against the device-config namespace. None of /// these has been observed on a wire, in a capture, or in any protocol table — they are constructed /// from the naming conventions the *known* keys follow (`enable_…`, `…_enable`, snake_case, and the @@ -453,7 +472,13 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// Record one decoded reply. public mutating func noteReply(_ r: DeviceConfigReadProbe.ValueResponse, for step: Step) { setStatus(r.isUnsupported ? .unsupported : .answered, for: step.opcode) + #if os(macOS) + // The macOS hardware run returned FAILURE with an echoed key and zero padding. Keep the + // low-level cross-platform decoder intact; this scoped report must not claim a failed value. + let value = r.resultCode == nil || r.resultCode == 1 ? r.value(for: step.key) : nil + #else let value = r.value(for: step.key) + #endif readings.append(Reading(group: step.group, opcode: step.opcode, key: step.key, value: value, resultCode: r.resultCode, recordHex: r.recordHex)) var line = "\(DeviceConfigReadProbeReport.opcodeLabel(step.opcode)) key=\"\(step.key)\"" @@ -541,7 +566,14 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { } let named = readings.filter { $0.value != nil }.count if named == 0 { + #if os(macOS) + if readings.allSatisfy({ $0.resultCode != nil && $0.resultCode != 1 }) { + return "\(answered) of 2 read verbs answered, but no reply reported success; no value is claimed" + } + return "\(answered) of 2 read verbs answered, but no successful reply carried a verified key/value pair; no value is claimed" + #else return "\(answered) of 2 read verbs answered, but no reply echoed its key so no value is claimed" + #endif } return "\(answered) of 2 read verbs answered; read \(named) config value(s)" } @@ -563,8 +595,12 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { sb += section(.discovery, title: "Discovery — one round-trip per verb against a key it should know", empty: "(none — no reply was decoded)") + let writeNames = Set(Whoop5Config.enableR22Sequence.map(\.name)) + let flagTitle = knownFlagKeys.allSatisfy(writeNames.contains) + ? "Known feature-flag values (names NOOP already writes; values never read before)" + : "Known feature-flag values (includes names observed in strap enumeration)" sb += section(.knownFlag, - title: "Known feature-flag values (names NOOP already writes; values never read before)", + title: flagTitle, empty: "(none — the verb that would carry them did not answer)") sb += section(.candidate, title: "Candidate oxygen keys — GUESSES, never observed on a wire or in any table", diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift index 0151818b65..ca0dea4ca5 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift @@ -51,6 +51,24 @@ final class DeviceConfigReadProbeTests: XCTestCase { private var flagKeys: [String] { Whoop5Config.enableR22Sequence.map(\.name) } + func testObservedWhoop5FlagsAreReadWithoutExtendingTheWriteSequence() { + let keys = DeviceConfigReadProbe.knownFlagKeys(for: .whoop5) + var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: keys, + candidateKeys: []) + while let step = report.nextStep() { + XCTAssertTrue(DeviceConfigReadProbe.isReadOnlyOpcode(step.opcode)) + report.noteReply(.init(resultCode: 1, record: echoRecord(step.key, value: 0x31)), for: step) + } + let flagReads = report.readings.filter { $0.opcode == DeviceConfigReadProbe.getFeatureFlagValueCmd } + XCTAssertEqual(flagReads.count, 20, "the complete hardware enumeration contained twenty names") + XCTAssertEqual(Set(flagReads.map(\.key)), Set(keys)) + XCTAssertEqual(report.steps, 21, "one read per flag plus the existing device-config discovery") + XCTAssertEqual(flagKeys.count, 16, "the write sequence must not inherit read-only discoveries") + XCTAssertEqual(DeviceConfigReadProbe.knownFlagKeys(for: .whoop4), flagKeys) + XCTAssertTrue(report.render().contains("includes names observed in strap enumeration")) + XCTAssertFalse(report.render().contains("names NOOP already writes; values never read before")) + } + // MARK: - The read-only allowlist (the hard safety constraint) func testAllowlistAdmitsOnlyTheTwoReadVerbs() { @@ -141,6 +159,42 @@ final class DeviceConfigReadProbeTests: XCTestCase { XCTAssertNil(r.value(for: "whatever"), "an UNSUPPORTED reply must never yield a value") } + #if os(macOS) + func testEchoedFailureBytesAreNotReportedAsStoredValues() { + // The live WHOOP 5 oxygen-key reads returned FAILURE with the requested key and zeroes. + for result in [UInt8(0), 2, 3] { + let frame = whoop5Response(cmd: 121, + payload: payload(result: result, + record: echoRecord("enable_spo2", value: 0))) + guard case .success(let reply) = DeviceConfigReadProbe.parse(frame: frame, family: .whoop5, + expecting: 121) else { + return XCTFail("the response is valid framing even when the read failed") + } + var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: [], + candidateKeys: []) + report.noteReply(reply, for: .init(opcode: 121, key: "enable_spo2", group: .candidate)) + XCTAssertEqual(reply.value(for: "enable_spo2"), 0, "the shared byte decoder remains unchanged") + XCTAssertNil(report.readings.first?.value) + if result == 3 { + XCTAssertEqual(report.verdict, + "no read verb answered — GET_FF_VALUE(128) not asked; GET_DEVICE_CONFIG_VALUE(121) refused by firmware (UNSUPPORTED)") + } else { + XCTAssertEqual(report.verdict, + "1 of 2 read verbs answered, but no reply reported success; no value is claimed") + } + XCTAssertFalse(report.render().contains("value=0x00")) + } + } + + func testSuccessfulReplyWithoutAKeyValueHasADistinctVerdict() { + var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: [], candidateKeys: []) + report.noteReply(.init(resultCode: 1, record: [1, 0]), + for: .init(opcode: 128, key: "enable_r22_packets", group: .discovery)) + XCTAssertEqual(report.verdict, + "1 of 2 read verbs answered, but no successful reply carried a verified key/value pair; no value is claimed") + } + #endif + func testNoValueIsClaimedWhenTheReplyDoesNotEchoTheKey() { // A plausible-looking record that simply isn't the key we asked for. let frame = whoop5Response(cmd: 128, payload: payload(result: 1, record: echoRecord("some_other_key", value: 0x32))) diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index f278b32f99..10d12f5d76 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -4021,10 +4021,17 @@ public final class BLEManager: NSObject, ObservableObject { log("Device-config read probe (#103) ignored — a probe is already walking its plan") return } + // Platform exception: this hardware investigation is explicitly scoped to macOS. + // Keep iOS and Android's established plans until the additional reads are verified there. + #if os(macOS) + let flagKeys = DeviceConfigReadProbe.knownFlagKeys(for: selectedModel.deviceFamily) + #else + let flagKeys = Whoop5Config.enableR22Sequence.map(\.name) + #endif deviceConfigReport = DeviceConfigReadProbeReport( family: selectedModel.deviceFamily, - // The flag names come from NOOP's own R22 sequence — never restated here. - knownFlagKeys: Whoop5Config.enableR22Sequence.map(\.name), + // Include names observed on the strap without changing the R22 write sequence. + knownFlagKeys: flagKeys, candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) state.deviceConfigProbe = BLEManager.deviceConfigProbeWaiting log("Device-config read probe (#103): asking for config VALUES via GET_DEVICE_CONFIG_VALUE(121) + GET_FF_VALUE(128) on family=\(selectedModel.deviceFamily); read-only (SET_FF_VALUE/120 and SET_DEVICE_CONFIG_VALUE/119 are never sent from this path)") From 75668eeca9042f373e1a14872a96cdc5cb7d76b6 Mon Sep 17 00:00:00 2001 From: Benjamin Woolston Date: Tue, 15 Sep 2026 10:17:06 +1000 Subject: [PATCH 2/2] test(protocol): pin that a failed flag read is not reported as a value Add a guard-only regression test for the result-code check in DeviceConfigReadProbeReport.noteReply. A CRC-valid WHOOP 5 GET_FF_VALUE(128) FAILURE reply echoing the key with a zero byte must not produce a reading value or a rendered value=, while a SUCCESS reply with the identical record still reports a genuine 0. The test uses only API present on main, so removing the check fails an assertion rather than the build. --- .../DeviceConfigReadProbeTests.swift | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift index ca0dea4ca5..38384548ef 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift @@ -193,6 +193,35 @@ final class DeviceConfigReadProbeTests: XCTestCase { XCTAssertEqual(report.verdict, "1 of 2 read verbs answered, but no successful reply carried a verified key/value pair; no value is claimed") } + + func testReportDoesNotPresentAFailedReadAsAStoredValue() { + // Guard-only: uses nothing this change added, so dropping the result-code check fails an + // assertion here rather than the build. The FAILURE and SUCCESS frames carry identical records. + let record = echoRecord("enable_rocky2", value: 0) + func report(result: UInt8) -> DeviceConfigReadProbeReport { + let frame = whoop5Response(cmd: 128, payload: payload(result: result, record: record)) + guard case .success(let reply) = DeviceConfigReadProbe.parse(frame: frame, family: .whoop5, + expecting: 128) else { + XCTFail("the response is valid framing even when the read failed") + return DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: [], candidateKeys: []) + } + XCTAssertEqual(reply.value(for: "enable_rocky2"), 0, + "the fixture must be the dangerous shape: key echoed, zero byte after the field") + var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: [], candidateKeys: []) + report.noteReply(reply, for: .init(opcode: 128, key: "enable_rocky2", group: .knownFlag)) + return report + } + + let failed = report(result: 0) + XCTAssertEqual(failed.readings.count, 1, "the rejected read is still recorded") + XCTAssertEqual(failed.readings.first?.resultCode, 0) + XCTAssertNil(failed.readings.first?.value, "a FAILURE reply must not be reported as a stored 0") + XCTAssertFalse(failed.render().contains("value=")) + + let succeeded = report(result: 1) + XCTAssertEqual(succeeded.readings.first?.value, 0, "a SUCCESS reply holding 0 is still a real 0") + XCTAssertTrue(succeeded.render().contains("value=0x00")) + } #endif func testNoValueIsClaimedWhenTheReplyDoesNotEchoTheKey() {