Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)\""
Expand Down Expand Up @@ -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)"
}
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)))
Expand Down
11 changes: 9 additions & 2 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down