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..38384548ef 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,71 @@ 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") + } + + 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() { // 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)")