diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/HelloIdentityProbe.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/HelloIdentityProbe.swift index d60866571b..e655e56125 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/HelloIdentityProbe.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/HelloIdentityProbe.swift @@ -6,22 +6,26 @@ import Foundation /// serial decoded anywhere: `BatteryPackInfo.serial` is the BATTERY PACK's, read via cmd 151 and /// answered only by a 5/MG, so it identifies a removable part rather than the strap wearing it. /// -/// The 4.0 hunt has a capture aid already (the `GET_HELLO_HARVARD` (35) branch, which now reports -/// through this same probe — its payload carries a device key beside the serial). This is the 5/MG -/// half, and it needs no new traffic: the GET_HELLO block is ALREADY decoded — for the device name at -/// `pay[16]` and the firmware version at `pay[93]` — and everything else in it is simply discarded. If -/// the serial is in there, it is arriving on every connect and being thrown away. +/// The 4.0 hunt had a capture aid for the `GET_HELLO_HARVARD` (35) response until the fork narrowed to +/// 5/MG; nothing calls this probe for it now, and `report`'s `block` label is what remains of it. This is +/// the 5/MG half, and it needs no new traffic: the GET_HELLO block is ALREADY decoded — for the device name +/// at `pay[16]` and the firmware version at `pay[93]`, in the decoder's payload, which starts at frame +/// byte 11 — and everything else in it is simply discarded. If the serial is in there, it is arriving on +/// every connect and being thrown away. +/// +/// Offsets in a report count from the start of the payload the caller passes. FrameRouter passes the one +/// after the result byte (frame byte 13), whose offsets match the response-body tables in +/// `docs/PROTOCOL_TRANSPORT.md` and sit two below the decoder's `pay` (W06-103); its line says so. /// /// ## Why this reports structure instead of dumping the block /// /// The same response carries a SESSION TOKEN, which the decoder deliberately never reads. Dumping the /// payload wholesale would put that token in a log, so this reports every printable run as -/// offset + length + class and prints the CONTENTS only of runs that could plausibly be the serial and -/// are not the already-known name: fully alphanumeric, [serialLength] characters long. -/// -/// That rule is a filter, not a guarantee — a token that happened to be alphanumeric and serial-length -/// would print. Which is precisely why the caller gates this behind an opt-in diagnostic rather than -/// the default, shareable strap log. +/// offset + length + class and quotes only runs that could plausibly be the serial: fully alphanumeric, +/// [serialLength] characters long. Even those print only their first three characters (W06-092), the way +/// `WhoopSerialIdentity.logSafe` logs a serial everywhere else. With the length, that is enough to match a +/// run against the serial on the strap's casing, and a serial, or a token that happened to look like one, +/// stays out of a log a user may export. The caller still gates this behind an opt-in diagnostic. /// /// Pure and Foundation-only, so it unit-tests with no strap, no BLE and no app. public enum HelloIdentityProbe { @@ -32,18 +36,24 @@ public enum HelloIdentityProbe { (48...57).contains(b) || (65...90).contains(b) || (97...122).contains(b) } + /// `whoop5HelloNameOffset` in a payload that starts after the COMMAND_RESPONSE origin sequence and result + /// (frame byte 13), the form `FrameRouter.commandResponsePayload` returns (W06-076). + public static let whoop5NameOffsetAfterResult = whoop5HelloNameOffset - 2 + /// Printable-ASCII runs in a GET_HELLO payload, one line each, for a diagnostic log. /// /// - Parameters: /// - payload: the GET_HELLO (145) response payload, token region included — nothing is stripped /// before this call; the withholding happens here so a caller cannot get it wrong. - /// - knownNameOffset: where the decoder already reads the device name (16 on the pinned capture). + /// - knownNameOffset: where the decoder already reads the device name, in this payload's coordinates. + /// The default is the decoder's own (`whoop5HelloNameOffset`, payload from frame byte 11). /// A run starting there is labelled rather than printed, since it is not a serial candidate and /// is already surfaced elsewhere. /// - minRun: shortest printable run worth reporting. Below this, a binary payload produces noise. - /// - serialLength: run lengths that could be a serial. Outside it, contents are withheld. + /// - serialLength: run lengths that could be a serial. Inside it, a run's first three characters print; + /// outside it, none do. public static func candidateLines(payload: [UInt8], - knownNameOffset: Int = 16, + knownNameOffset: Int = whoop5HelloNameOffset, minRun: Int = 4, serialLength: ClosedRange = 6...20) -> [String] { var out: [String] = [] @@ -64,7 +74,7 @@ public enum HelloIdentityProbe { // not what the withholding rule exists to protect. if start == knownNameOffset { line += " (device name, already decoded)" } if alnum, serialLength.contains(run.count) { - line += " \"\(String(decoding: run, as: UTF8.self))\"" + line += " \"\(String(decoding: run.prefix(3), as: UTF8.self))…\"" } else if start != knownNameOffset { line += " (withheld)" } @@ -79,11 +89,11 @@ public enum HelloIdentityProbe { /// answer — it says the serial is not ASCII in this block and the search moves elsewhere. /// /// - Parameter block: which response this payload came from, for the line's prefix. Defaults to the - /// 5/MG `GET_HELLO` this probe was written for; the WHOOP 4.0 `GET_HELLO_HARVARD(35)` capture aid - /// passes its own so one log can carry both without the two reading as the same frame. + /// 5/MG `GET_HELLO` this probe was written for; a caller reporting another block passes its own, so + /// one log can carry both without the two reading as the same frame. public static func report(payload: [UInt8], block: String = "HELLO(145)", - knownNameOffset: Int = 16, + knownNameOffset: Int = whoop5HelloNameOffset, minRun: Int = 4, serialLength: ClosedRange = 6...20) -> String { let lines = candidateLines(payload: payload, knownNameOffset: knownNameOffset, diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift index 187a3696bc..5de5fc6f26 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift @@ -928,13 +928,19 @@ public func whoop5HistoricalAckFrame(endData: [UInt8], seq: UInt8) -> [UInt8] { puffinCommandFrame(cmd: 23, seq: seq, payload: [0x01] + endData) } +/// Where a WHOOP 5/MG GET_HELLO (145) reply carries the device name, in the COMMAND_RESPONSE payload as +/// `decodeWhoop5CommandResponse` reads it: from frame byte 11, the origin sequence. Anchored to a 50.38.1.0 +/// capture. `HelloIdentityProbe.whoop5NameOffsetAfterResult` derives from it, so the #1303 probe labels the +/// run this decoder reads. +public let whoop5HelloNameOffset = 16 + /// Decode a WHOOP 5.0 COMMAND_RESPONSE (type 36) — battery %, history data-range, firmware version. /// /// The response command is at frame[10] (the 4.0 frame[6] + 4) and its payload at frame[11]. WHOOP 5 /// reuses the 4.0 command NUMBERS, but the response PAYLOADS differ from 4.0 — so each field below is /// mapped from a real WHOOP 5 capture (firmware 50.38.1.0), not ported on faith. Commands that return -/// a short stub on this firmware (REPORT_VERSION_INFO / GET_EXTENDED_BATTERY_INFO) or aren't served -/// (GET_CLOCK — unneeded, since realtime + historical carry real unix) are intentionally left undecoded. +/// a short stub on this firmware (REPORT_VERSION_INFO / GET_EXTENDED_BATTERY_INFO) are intentionally left +/// undecoded. The GET_CLOCK reply is decoded by `StrapClock.decodeReply` rather than here. private func decodeWhoop5CommandResponse(_ frame: [UInt8], fb: FieldBuilder, schema: Schema, payloadEnd: Int?, limit: Int) { guard let payloadEnd = payloadEnd, 11 < payloadEnd, payloadEnd <= frame.count else { return } @@ -967,15 +973,17 @@ private func decodeWhoop5CommandResponse(_ frame: [UInt8], fb: FieldBuilder, sch fb.parsed["history_oldest"] = .int(Int(oldest)) fb.parsed["history_newest"] = .int(Int(newest)) } - } else if respCmd == 145, pay.count >= 26 { - // GET_HELLO info block. We surface the two user-facing fields the app shows — the device NAME + } else if respCmd == 145, pay.count >= 26, pay[1] == 1 { + // GET_HELLO info block, carried only by a SUCCESS (1) reply. The PENDING acknowledgement before it is + // zeros after the revision (`docs/PROTOCOL_TRANSPORT.md` §Hello), and decoding it reported a + // firmware-gate failure on every connect (W06-087). We surface the two user-facing fields the app shows — the device NAME // (the model-style label the strap calls itself) and the firmware VERSION — and deliberately never // read the session token (also in this response). Both offsets are anchored to a real // 50.38.1.0 capture: the name is printable ASCII at pay[16]; the version is 4 bytes at pay[93], // after the (fixed-width on this firmware) name+token region. Re-verify the version offset // across firmwares; the guards (printable name / pay[93]==50 "5.0" generation) fail closed. var nameBytes: [UInt8] = [] - var i = 16 + var i = whoop5HelloNameOffset while i < pay.count, pay[i] != 0, (32...126).contains(pay[i]), nameBytes.count < 24 { nameBytes.append(pay[i]); i += 1 } diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/StrapClock.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/StrapClock.swift new file mode 100644 index 0000000000..1899530820 --- /dev/null +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/StrapClock.swift @@ -0,0 +1,257 @@ +import Foundation + +/// The WHOOP 5/MG strap clock as GET_CLOCK (11) reports it, and whether a connect sets it (W06-050). +/// +/// Every connect used to send SET_CLOCK and then GET_CLOCK. The strap applies a SET_CLOCK value when it +/// processes the command, and on a busy link (a state-restoration relaunch) that came about four seconds +/// after the phone stamped the value, so the strap then ran that far behind. The next prompt set stepped it +/// forward again, and when that set came from a relaunch during a Raw Data Collector session the IMU +/// timeline skipped the seconds of the step. Reading first, and setting only a clock that is invalid, provably +/// more than `driftThresholdSeconds` off, or unread (a refused reply, or no reading in `replyTimeoutSeconds`), +/// removes both steps for a strap whose clock is fine. +public enum StrapClock { + + /// Seconds at or below this are not a wall clock. The 146/147 clock contract applies a stored time only + /// above it (`docs/PROTOCOL_TRANSPORT.md` §Clock and identity contracts) and warns that a failed read can + /// return zero with a success result. That contract is for the other command pair; here the floor is + /// only a validity bound for the command-11 reply, so a reading at or below it needs a set whatever the + /// result byte says. + public static let validityFloor: UInt32 = 1_293_840_001 + + /// How far the strap clock may sit from the phone's before a connect sets it. + public static let driftThresholdSeconds: Double = 2 + + /// How long a connect waits for the GET_CLOCK reply before setting the clock without one. Replies on a + /// busy relaunch came about four seconds after the request; a firmware that does not serve GET_CLOCK + /// never answers, and its strap must still be clocked, because an un-clocked WHOOP 5 banks no sensor data. + public static let replyTimeoutSeconds: Double = 10 + + /// A first read answered more than this long after it was sent, whose verdict is unresolved, is read once + /// more before the check decides (W06-084). Up to one second the range `judge` allows is at most two seconds + /// wide, so a strap exactly in sync is always judged in sync; above it, one can come out unresolved. On a + /// restored link the first read queues behind the relaunch's notify re-subscribe writes and is answered + /// 3–5 s later, while a second read, sent once they are through, is answered promptly. + public static let readAgainAfterRoundTripSeconds: Double = 1 + + /// The whole second a 5/MG SET_CLOCK stamps for phone Unix time `unix`: the nearest one. The set carries + /// whole seconds with zero subseconds, so truncating left the strap up to 1 s behind before any processing + /// delay, and a set the strap then applied late was judged off and set again on the next connect + /// (W06-075). Rounding bounds the stamp at half a second either way. + public static func setSeconds(forPhoneTime unix: Double) -> UInt32 { + UInt32(min(max(unix.rounded(), 0), Double(UInt32.max))) + } + + /// The COMMAND_RESPONSE result that carries a reading. Pending (2) promises a later answer. + public static let resultSuccess: UInt8 = 1 + public static let resultPending: UInt8 = 2 + + /// One GET_CLOCK reply. + public struct Reading: Equatable, Sendable { + /// The sequence of the request this answers (format-1 byte 11), which ties the reply to one read. + public let originSequence: UInt8 + /// The COMMAND_RESPONSE result (byte 12): 0 failure, 1 success, 2 pending, 3 unsupported. + public let result: UInt8 + /// Whole Unix seconds on the strap clock, or nil when the reply body stops before them. + public let seconds: UInt32? + + public init(originSequence: UInt8, result: UInt8, seconds: UInt32?) { + self.originSequence = originSequence; self.result = result; self.seconds = seconds + } + } + + /// Decode a format-1 COMMAND_RESPONSE to GET_CLOCK: type 36 at byte 8, command 11 at byte 10, the + /// request's sequence at 11, the result at 12 and the strap's Unix seconds as a u32 LE at 13. Twelve + /// distinct replies from firmware 50.39.1.0 have this layout. Seven more bytes follow, zero in every one + /// of them; each of those GETs ran in the same millisecond as the SET before it, so the bytes may be + /// sub-second ticks that read zero there. They are not read. + /// + /// Structure only: the caller passes a frame that passed `verifyFrame`. The body ends where the declared + /// length (bytes 2–3) puts the CRC32 trailer. Returns nil for any other frame or a body that stops before + /// the result. Only a SUCCESS reply carries seconds (W06-095): the bytes after any other result are not a + /// reading, and every line that prints one takes it from here, so none can print a clock the strap did not + /// report. + public static func decodeReply(_ frame: [UInt8]) -> Reading? { + guard frame.count >= 4 else { return nil } + let bodyEnd = 4 + (Int(frame[2]) | Int(frame[3]) << 8) + guard bodyEnd <= frame.count, bodyEnd > 12, frame[8] == 36, frame[10] == 11 else { return nil } + var seconds: UInt32? + if bodyEnd >= 17, frame[12] == resultSuccess { + seconds = UInt32(frame[13]) | UInt32(frame[14]) << 8 | UInt32(frame[15]) << 16 | UInt32(frame[16]) << 24 + } + return Reading(originSequence: frame[11], result: frame[12], seconds: seconds) + } + + /// What one reading says about the strap clock, as the offset strap − phone in seconds. + public enum Verdict: Equatable, Sendable { + /// No reading: the result was not success, or the reply carried no seconds. + case unread(result: UInt8) + /// A reading at or below `validityFloor`. + case invalid(seconds: UInt32) + /// The offset lies within the threshold either way. + case inSync(seconds: UInt32, low: Double, high: Double) + /// The offset may pass the threshold, but the round trip was too long to show that it does. + case unresolved(seconds: UInt32, low: Double, high: Double) + /// The offset lies wholly beyond the threshold. + case off(seconds: UInt32, low: Double, high: Double) + + /// Whether a connect should set the clock on this verdict. + public var needsSet: Bool { + switch self { + case .unread, .invalid, .off: return true + case .inSync, .unresolved: return false + } + } + } + + /// Judge a reading from a GET_CLOCK answered at phone Unix time `receivedAt`, `roundTrip` seconds after it + /// was sent. The strap read its clock at some moment in that window, and a whole-second reading `s` means + /// its clock stood in [s, s + 1) then. So the offset strap − phone lies in + /// (s − receivedAt, s + 1 − (receivedAt − roundTrip)), and the clock is judged off only when that whole + /// range lies past `driftThresholdSeconds`. The range is open, so a bound exactly on the threshold already + /// puts all of it past (W06-089). A long round trip widens the range and so never produces a set + /// by itself. The threshold is fixed rather than a parameter, because `describe` states it and `Verdict` + /// does not carry it (W06-082). + /// + /// The phone's wall clock is sampled once, at the reply, and the round trip must come from a monotonic + /// clock (W06-074): with two wall samples, a phone clock stepped back during the read collapsed the range + /// onto the pre-step send time and judged a correct strap off. The one wall sample is the clock a set would + /// copy, so a step before the reply is absorbed. A negative round trip counts as zero. + public static func judge(_ reading: Reading, receivedAt: Double, roundTrip: Double) -> Verdict { + guard reading.result == resultSuccess, let s = reading.seconds else { return .unread(result: reading.result) } + guard s > validityFloor else { return .invalid(seconds: s) } + let low = Double(s) - receivedAt + let high = Double(s) + 1 - (receivedAt - max(0, roundTrip)) + let threshold = driftThresholdSeconds + if high <= -threshold || low >= threshold { return .off(seconds: s, low: low, high: high) } + if low >= -threshold && high <= threshold { return .inSync(seconds: s, low: low, high: high) } + return .unresolved(seconds: s, low: low, high: high) + } + + /// One connection's clock check: one read, or two when the first is answered over a round trip too long to + /// judge it (W06-084), then a set only when the deciding verdict needs one. + public struct Check: Equatable, Sendable { + + /// A GET_CLOCK in flight: its request sequence and when it was sent, in monotonic seconds. + public struct Request: Equatable, Sendable { + public let sequence: UInt8 + public let sentAt: Double + } + + /// The read that decides this connection; nil once it has been answered or has expired. + public private(set) var read: Request? + /// The GET_CLOCK sent after a set, so its reply is recognised and reported rather than judged. + public private(set) var readback: Request? + /// True once the check has decided, by a reply or by the timeout. + public private(set) var settled = false + /// The first read's verdict while the check waits on its second read (W06-084); it stands if that read + /// goes unanswered. + public private(set) var standingVerdict: Verdict? + /// True once the check has asked for its one second read. + public private(set) var secondReadAsked = false + + public init() {} + + /// What a reply means for this connection. + public enum Step: Equatable, Sendable { + /// Answers no request in flight: a late or foreign reply, reported and otherwise ignored. + case notOurs + /// The strap acknowledged the read and will answer later. + case pending + /// The read is answered and the clock is left alone. + case settle(Verdict, roundTrip: Double) + /// The read is answered and the clock needs a set. + case set(Verdict, roundTrip: Double) + /// The read is answered, but over a round trip too long to judge it; read once more (W06-084). + case readAgain(Verdict, roundTrip: Double) + /// The reply to the readback after a set; `roundTrip` counts from the set. + case readback(Reading, roundTrip: Double) + } + + /// A read went out with request sequence `sequence` at `at`, in seconds on a monotonic clock: one that + /// no wall-clock step moves and that keeps counting while the device sleeps. + public mutating func beginRead(sequence: UInt8, at: Double) { + guard !settled else { return } + read = Request(sequence: sequence, sentAt: at) + } + + /// A readback went out after a set, at `at` on the same monotonic clock. + public mutating func beginReadback(sequence: UInt8, at: Double) { + readback = Request(sequence: sequence, sentAt: at) + } + + /// Fold in one decoded reply received at `at` on the monotonic clock of `beginRead`, when the phone's + /// wall clock read `wallClock` Unix seconds. + public mutating func receive(_ reading: Reading, at: Double, wallClock: Double) -> Step { + if let r = read, !settled, reading.originSequence == r.sequence { + if reading.result == resultPending { return .pending } + read = nil + let roundTrip = at - r.sentAt + let verdict = judge(reading, receivedAt: wallClock, roundTrip: roundTrip) + if case .unresolved = verdict, !secondReadAsked, roundTrip > readAgainAfterRoundTripSeconds { + secondReadAsked = true + standingVerdict = verdict + return .readAgain(verdict, roundTrip: roundTrip) + } + settled = true + standingVerdict = nil + return verdict.needsSet ? .set(verdict, roundTrip: roundTrip) : .settle(verdict, roundTrip: roundTrip) + } + if let r = readback, reading.originSequence == r.sequence { + if reading.result == resultPending { return .pending } + readback = nil + return .readback(reading, roundTrip: at - r.sentAt) + } + return .notOurs + } + + /// The read went unanswered for `replyTimeoutSeconds`. True when this settles the check: the caller then + /// sets the clock without a reading, unless `standingVerdict` holds a first reading, which stands instead. + /// False when a reply already settled it. + public mutating func expire() -> Bool { + guard !settled else { return false } + settled = true + read = nil + return true + } + } + + /// The log line for a verdict, stating the reading and the range it allows rather than a single offset + /// the reply cannot establish. + public static func describe(_ verdict: Verdict, roundTrip: Double) -> String { + let rt = String(format: "%.1f", max(0, roundTrip)) + switch verdict { + case .unread(let result): + return "GET_CLOCK answered result \(result) with no reading — setting the clock" + case .invalid(let s): + return "strap clock reads \(s), not a wall clock (at or below \(validityFloor)) — setting it" + case let .inSync(s, low, high): + return "strap clock reads \(s), \(range(low, high)) from the phone over a \(rt) s round trip — " + + "within \(Int(driftThresholdSeconds)) s, not set" + case let .unresolved(s, low, high): + return "strap clock reads \(s), \(range(low, high)) from the phone over a \(rt) s round trip — " + + "that does not show it more than \(Int(driftThresholdSeconds)) s off, not set" + case let .off(s, low, high): + return "strap clock reads \(s), \(range(low, high)) from the phone over a \(rt) s round trip — " + + "more than \(Int(driftThresholdSeconds)) s off, setting it" + } + } + + /// The log line for a first read answered over a round trip too long to judge it, before the second read + /// (W06-084). + public static func describeReadAgain(_ verdict: Verdict, roundTrip: Double) -> String { + guard case let .unresolved(s, low, high) = verdict else { return describe(verdict, roundTrip: roundTrip) } + let rt = String(format: "%.1f", max(0, roundTrip)) + return "strap clock reads \(s), \(range(low, high)) from the phone over a \(rt) s round trip — " + + "too long to judge, reading it again" + } + + /// The range rounded outward to tenths, so the printed range always holds the true one and a range printed + /// for `.off` cannot read as the same numbers as one printed for `.unresolved` (W06-089). Floating-point noise + /// is snapped first, so an exact tenth stays put, and a zero prints unsigned. + private static func range(_ low: Double, _ high: Double) -> String { + func tenths(_ x: Double, _ rule: FloatingPointRoundingRule) -> Double { + ((x * 10 * 1_000_000).rounded() / 1_000_000).rounded(rule) / 10 + 0 + } + return String(format: "%+.1f…%+.1f s", tenths(low, .down), tenths(high, .up)) + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FirmwareGateDiagnosticTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FirmwareGateDiagnosticTests.swift index 657bd66577..ad4b7d64d6 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FirmwareGateDiagnosticTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/FirmwareGateDiagnosticTests.swift @@ -46,4 +46,21 @@ final class FirmwareGateDiagnosticTests: XCTestCase { let line = firmwareGateDiagnostic(payload: Array(repeating: 0, count: 88), nameEndIndex: 20) XCTAssertTrue(line.hasSuffix("hex[88..<88]="), line) } + /// W06-087: the strap answers GET_HELLO first with PENDING and a zeroed body (`docs/PROTOCOL_TRANSPORT.md` + /// §Hello), which carries no block. Decoding it reported a firmware-gate failure on every connect. + func testThePendingHelloAcknowledgementDecodesNoBlock() { + let pending = puffinCommandFrame(cmd: 145, seq: 0x6d, payload: [0x01, 0x02] + [UInt8](repeating: 0, count: 107), + type: 36, header: [0x01, 0x00]) + let parsed = parseFrame(pending, family: .whoop5).parsed + XCTAssertNil(parsed["fw_gate"]) + XCTAssertNil(parsed["fw_version"]) + XCTAssertNil(parsed["device_name"]) + } + + /// The control: a SUCCESS reply whose version byte fails the guard still reports the gate, the #1634 evidence. + func testASuccessHelloThatFailsTheVersionGuardStillReportsTheGate() { + let success = puffinCommandFrame(cmd: 145, seq: 0x6d, payload: [0x01, 0x01] + [UInt8](repeating: 0, count: 107), + type: 36, header: [0x01, 0x00]) + XCTAssertNotNil(parseFrame(success, family: .whoop5).parsed["fw_gate"]) + } } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HelloIdentityProbeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HelloIdentityProbeTests.swift index 14aee981c2..499d6e4bbc 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HelloIdentityProbeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/HelloIdentityProbeTests.swift @@ -20,13 +20,38 @@ final class HelloIdentityProbeTests: XCTestCase { return p } - /// The point of the whole probe: a serial-shaped run away from the name is printed, so it can be - /// matched against the serial on the strap's own casing. + /// W06-076: the offset a caller passes for a payload that starts after the result byte (frame byte 13) is + /// the decoder's own name offset in those coordinates, so the run it labels is the name the decoder reads. + func testTheAfterResultNameOffsetIsWhereTheDecoderReadsTheName() { + var body = [UInt8](repeating: 0, count: 110) + let nameAt = whoop5HelloNameOffset - 2 // the payload below starts with the origin sequence and result + for (k, b) in Array("WHOOP-FAKE01".utf8).enumerated() { body[nameAt + k] = b } + let frame = puffinCommandFrame(cmd: 145, seq: 0x6d, payload: [0x01, 0x01] + body, type: 36, + header: [0x01, 0x00]) + XCTAssertEqual(parseFrame(frame, family: .whoop5).parsed["device_name"]?.stringValue, "WHOOP-FAKE01") + let afterResult = Array(frame[13..<(4 + (Int(frame[2]) | Int(frame[3]) << 8))]) + let offset = HelloIdentityProbe.whoop5NameOffsetAfterResult + XCTAssertEqual(HelloIdentityProbe.candidateLines(payload: afterResult, knownNameOffset: offset), + ["off=14 len=12 mixed (device name, already decoded)"]) + } + + /// The point of the whole probe: a serial-shaped run away from the name is quoted, by its first three + /// characters (W06-092), so it can be matched against the serial on the strap's own casing. func testASerialShapedRunIsPrinted() { let p = payload(length: 120, runs: [(40, "3A1B2405003655")]) let lines = HelloIdentityProbe.candidateLines(payload: p) XCTAssertEqual(lines.count, 1) - XCTAssertEqual(lines.first, #"off=40 len=14 alnum "3A1B2405003655""#) + XCTAssertEqual(lines.first, #"off=40 len=14 alnum "3A1…""#) + } + + /// W06-092: a serial-shaped run prints only its first three characters, the way `WhoopSerialIdentity.logSafe` + /// logs a serial everywhere else. With its length that is enough to match it against the serial on the + /// strap's casing, and the id itself stays out of a log a user may export. + func testASerialShapedRunPrintsOnlyItsFirstThreeCharacters() { + let p = payload(length: 120, runs: [(40, "3A1B2405003655")]) + let line = HelloIdentityProbe.report(payload: p) + XCTAssertFalse(line.contains("3A1B2405003655"), line) + XCTAssertEqual(line, #"HELLO(145) block len=120 runs: off=40 len=14 alnum "3A1…""#) } /// The privacy contract. A NON-alphanumeric run is described but never quoted — that is the shape a @@ -63,7 +88,7 @@ final class HelloIdentityProbeTests: XCTestCase { func testASerialShapedRunAtTheNameOffsetIsStillPrinted() { let p = payload(length: 120, runs: [(16, "3A1B2405003655")]) XCTAssertEqual(HelloIdentityProbe.candidateLines(payload: p).first, - #"off=16 len=14 alnum (device name, already decoded) "3A1B2405003655""#) + #"off=16 len=14 alnum (device name, already decoded) "3A1…""#) } /// Short runs are dropped. Binary payloads throw off two- and three-byte printable sequences by @@ -79,7 +104,7 @@ final class HelloIdentityProbeTests: XCTestCase { let lines = HelloIdentityProbe.candidateLines(payload: p) XCTAssertEqual(lines, [ "off=16 len=12 mixed (device name, already decoded)", - #"off=40 len=10 alnum "SER1234567""#, + #"off=40 len=10 alnum "SER…""#, "off=80 len=8 mixed (withheld)", ]) } @@ -89,7 +114,7 @@ final class HelloIdentityProbeTests: XCTestCase { var p = [UInt8](repeating: 0, count: 20) for (k, b) in Array("SERIAL99".utf8).enumerated() { p[12 + k] = b } XCTAssertEqual(HelloIdentityProbe.candidateLines(payload: p).first, - #"off=12 len=8 alnum "SERIAL99""#) + #"off=12 len=8 alnum "SER…""#) } /// "No printable runs" is a real answer, not a failure: it says the serial is not ASCII here and the @@ -106,7 +131,7 @@ final class HelloIdentityProbeTests: XCTestCase { /// the default range prints — which is only observable if the value actually reaches the scan. func testReportThreadsItsTuningThrough() { let p = payload(length: 120, runs: [(40, "3A1B2405003655")]) // 14 chars: inside the default 6...20 - XCTAssertTrue(HelloIdentityProbe.report(payload: p).contains(#""3A1B2405003655""#)) + XCTAssertTrue(HelloIdentityProbe.report(payload: p).contains(#""3A1…""#)) XCTAssertEqual(HelloIdentityProbe.report(payload: p, serialLength: 6...10), "HELLO(145) block len=120 runs: off=40 len=14 alnum (withheld)") } @@ -129,8 +154,9 @@ final class HelloIdentityProbeTests: XCTestCase { let line = HelloIdentityProbe.report(payload: p, block: "HELLO_HARVARD(35)", knownNameOffset: -1) XCTAssertEqual(line, "HELLO_HARVARD(35) block len=131 runs: " - + #"off=14 len=9 alnum "4C1246632"; off=24 len=54 alnum (withheld)"#) + + #"off=14 len=9 alnum "4C1…"; off=24 len=54 alnum (withheld)"#) XCTAssertFalse(line.contains(key), "the device key must never reach the log") + XCTAssertFalse(line.contains(serial), "nor the serial, beyond its first three characters (W06-092)") } /// The `block` label must reach the prefix — a default-only implementation would still pass every diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StrapClockTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StrapClockTests.swift new file mode 100644 index 0000000000..6720257f0e --- /dev/null +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/StrapClockTests.swift @@ -0,0 +1,441 @@ +import XCTest +@testable import WhoopProtocol + +/// W06-050 and W06-001: the GET_CLOCK reply decode and the read-first clock policy. The three GET_CLOCK +/// frames and the SET_CLOCK frame are real replies from one WHOOP MG on firmware 50.39.1.0; the failure and +/// pending replies are built with `puffinCommandFrame`, since no strap log holds one. +final class StrapClockTests: XCTestCase { + + private func bytes(_ s: String) -> [UInt8] { + stride(from: 0, to: s.count, by: 2).map { i in + let a = s.index(s.startIndex, offsetBy: i) + return UInt8(s[a...s.index(a, offsetBy: 1)], radix: 16)! + } + } + + /// A format-1 COMMAND_RESPONSE with the envelope header bytes the strap sends (01 00). + private func reply(cmd: UInt8, origin: UInt8, result: UInt8, body: [UInt8] = []) -> [UInt8] { + puffinCommandFrame(cmd: cmd, seq: 0x40, payload: [origin, result] + body, type: 36, header: [0x01, 0x00]) + } + + private func u32(_ v: UInt32) -> [UInt8] { (0..<4).map { UInt8((v >> (8 * $0)) & 0xFF) } } + + // MARK: decodeReply + + func testRealGetClockRepliesDecodeTheirSecondsAndOrigin() { + let cases: [(String, UInt8, UInt32)] = [ + ("aa011400010021b124230be80146aaaf6a0000000000000014cbbb6c", 0xe8, 1_789_897_286), + ("aa011400010021b124700b020194b5b66a0000000000000005a36ce6", 0x02, 1_790_358_932), + ("aa011400010021b124800b0201bab6b66a00000000000000c0966a91", 0x02, 1_790_359_226), + ] + for (hex, origin, seconds) in cases { + let frame = bytes(hex) + XCTAssertTrue(verifyFrame(frame, family: .whoop5).ok, hex) + XCTAssertEqual(StrapClock.decodeReply(frame), + StrapClock.Reading(originSequence: origin, result: 1, seconds: seconds), hex) + } + } + + func testOtherRepliesAreNotAClockReading() { + let setClockReply = bytes("aa010c000100271124220ae701000000bcc4efc3") + XCTAssertTrue(verifyFrame(setClockReply, family: .whoop5).ok) + XCTAssertNil(StrapClock.decodeReply(setClockReply)) + // Command 11 in a COMMAND frame (type 35) is our own request, not a reply. The body passes both length + // guards, so only the type check can reject it (W06-097: a padded empty request never reached it). + XCTAssertNil(StrapClock.decodeReply(w5Frame([7, 1] + u32(1_790_000_000), type: 35, cmd: 11))) + XCTAssertNil(StrapClock.decodeReply([0xAA, 0x01])) + } + + /// W06-097: the guards sit on bodies ending at 12/13 (the result) and 16/17 (the seconds). `puffinCommandFrame` + /// pads to 4 bytes, so its fixtures end at 12, 16, 20 or 24 and a guard could move between them unseen; + /// `w5Frame` builds the exact lengths. + func testTheGuardsSitOnTheResultAndTheSeconds() { + let seconds = u32(1_790_000_000) + XCTAssertNil(StrapClock.decodeReply(w5Frame([7], type: 36, cmd: 11)), "ends at 12: no result") + XCTAssertEqual(StrapClock.decodeReply(w5Frame([7, 1], type: 36, cmd: 11)), + StrapClock.Reading(originSequence: 7, result: 1, seconds: nil), "ends at 13: result only") + XCTAssertEqual(StrapClock.decodeReply(w5Frame([7, 1] + seconds.prefix(3), type: 36, cmd: 11)), + StrapClock.Reading(originSequence: 7, result: 1, seconds: nil), "ends at 16: three bytes of four") + XCTAssertEqual(StrapClock.decodeReply(w5Frame([7, 1] + seconds, type: 36, cmd: 11)), + StrapClock.Reading(originSequence: 7, result: 1, seconds: 1_790_000_000), "ends at 17: a reading") + } + + func testAFailureReplyWithNoBodyDecodesWithoutSeconds() { + let frame = reply(cmd: 11, origin: 7, result: 3) + XCTAssertTrue(verifyFrame(frame, family: .whoop5).ok) + XCTAssertEqual(StrapClock.decodeReply(frame), StrapClock.Reading(originSequence: 7, result: 3, seconds: nil)) + } + + /// W06-095: only a SUCCESS reply carries a reading. A failure, pending or unsupported reply with a full-length + /// body decoded seconds 0, a 1970 clock, which the clock line printed beside a verdict of "no reading". + func testANonSuccessReplyWithAFullBodyDecodesNoSeconds() { + for result: UInt8 in [0, 2, 3] { + let frame = reply(cmd: 11, origin: 7, result: result, body: [UInt8](repeating: 0, count: 11)) + XCTAssertTrue(verifyFrame(frame, family: .whoop5).ok) + XCTAssertEqual(StrapClock.decodeReply(frame), + StrapClock.Reading(originSequence: 7, result: result, seconds: nil), "\(result)") + } + } + + func testTheBodyEndsAtTheDeclaredLengthNotTheBufferEnd() { + // Extra bytes after the frame (a reassembly overrun) must not be read as seconds. + let frame = reply(cmd: 11, origin: 7, result: 1) + u32(1_790_000_000) + XCTAssertEqual(StrapClock.decodeReply(frame)?.seconds, nil) + } + + // MARK: judge + + private let t0 = 1_790_000_000.25 // phone time the read was sent; each reply time is t0 + its round trip + + func testAClockWithinTheThresholdIsNotSet() { + let r = StrapClock.Reading(originSequence: 1, result: 1, seconds: 1_790_000_000) + let v = StrapClock.judge(r, receivedAt: t0 + 0.5, roundTrip: 0.5) + XCTAssertEqual(v, .inSync(seconds: 1_790_000_000, low: -0.75, high: 0.75)) + XCTAssertFalse(v.needsSet) + } + + func testAClockProvablyBehindIsSet() { + // Four seconds behind, answered quickly: the whole range lies past −2 s. + let r = StrapClock.Reading(originSequence: 1, result: 1, seconds: 1_789_999_996) + let v = StrapClock.judge(r, receivedAt: t0 + 0.5, roundTrip: 0.5) + XCTAssertEqual(v, .off(seconds: 1_789_999_996, low: -4.75, high: -3.25)) + XCTAssertTrue(v.needsSet) + } + + func testAClockProvablyAheadIsSet() { + let r = StrapClock.Reading(originSequence: 1, result: 1, seconds: 1_790_000_005) + XCTAssertEqual(StrapClock.judge(r, receivedAt: t0 + 1, roundTrip: 1), + .off(seconds: 1_790_000_005, low: 3.75, high: 5.75)) + } + + func testALongRoundTripNeverSetsByItself() { + // The relaunch shape: the reply came four seconds after the request and reads the second it was + // sent. The strap may have read its clock at once (in sync) or four seconds later (four behind). + let r = StrapClock.Reading(originSequence: 1, result: 1, seconds: 1_790_000_000) + let v = StrapClock.judge(r, receivedAt: t0 + 4, roundTrip: 4) + XCTAssertEqual(v, .unresolved(seconds: 1_790_000_000, low: -4.25, high: 0.75)) + XCTAssertFalse(v.needsSet) + } + + /// W06-089: the offset lies in the open range (low, high), so a bound exactly on the threshold already puts + /// the whole range past it. This test used to pin high == −2 as not off. + func testARangeWhoseBoundSitsOnTheThresholdIsOff() { + let behind = StrapClock.Reading(originSequence: 1, result: 1, seconds: 1_789_999_997) + XCTAssertEqual(StrapClock.judge(behind, receivedAt: 1_790_000_000.5, roundTrip: 0.5), + .off(seconds: 1_789_999_997, low: -3.5, high: -2.0)) + let ahead = StrapClock.Reading(originSequence: 1, result: 1, seconds: 1_790_000_002) + XCTAssertEqual(StrapClock.judge(ahead, receivedAt: 1_790_000_000.0, roundTrip: 0.5), + .off(seconds: 1_790_000_002, low: 2.0, high: 3.5)) + // A second nearer, the range straddles the threshold again. + let nearer = StrapClock.Reading(originSequence: 1, result: 1, seconds: 1_789_999_998) + XCTAssertFalse(StrapClock.judge(nearer, receivedAt: 1_790_000_000.5, roundTrip: 0.5).needsSet) + } + + func testNoWallClockIsSetWhateverTheResult() { + for s: UInt32 in [0, 86_400, StrapClock.validityFloor] { + let v = StrapClock.judge(.init(originSequence: 1, result: 1, seconds: s), receivedAt: t0, roundTrip: 0) + XCTAssertEqual(v, .invalid(seconds: s)) + XCTAssertTrue(v.needsSet) + } + // One second past the floor is a wall clock, and this one is decades off. + let past = StrapClock.judge(.init(originSequence: 1, result: 1, seconds: StrapClock.validityFloor + 1), + receivedAt: t0, roundTrip: 0) + guard case .off = past else { return XCTFail("\(past)") } + } + + func testAReplyWithoutAReadingIsSet() { + let refused = StrapClock.judge(.init(originSequence: 1, result: 3, seconds: 1_790_000_000), + receivedAt: t0, roundTrip: 0) + XCTAssertEqual(refused, .unread(result: 3)) + XCTAssertEqual(StrapClock.judge(.init(originSequence: 1, result: 1, seconds: nil), receivedAt: t0, roundTrip: 0), + .unread(result: 1)) + XCTAssertTrue(refused.needsSet) + } + + /// W06-074: the phone runs 4 s fast and is stepped back to the right time during a 0.25 s read of a strap + /// that is right. With the send time taken from the wall clock (T + 4) the range was −4…−3 s and the strap + /// was set; from the reply's wall time and the monotonic round trip it is in sync. + func testAPhoneClockSteppedBackDuringTheReadDoesNotSetACorrectStrap() { + let T = 1_790_000_000.0 + let r = StrapClock.Reading(originSequence: 1, result: 1, seconds: 1_790_000_000) + XCTAssertEqual(StrapClock.judge(r, receivedAt: T + 0.25, roundTrip: 0.25), + .inSync(seconds: 1_790_000_000, low: -0.25, high: 1.0)) + } + + func testANegativeRoundTripCountsAsZero() { + let r = StrapClock.Reading(originSequence: 1, result: 1, seconds: 1_790_000_000) + XCTAssertEqual(StrapClock.judge(r, receivedAt: t0, roundTrip: -3), + StrapClock.judge(r, receivedAt: t0, roundTrip: 0)) + } + + // MARK: setSeconds + + func testASetStampsTheNearestSecond() { + XCTAssertEqual(StrapClock.setSeconds(forPhoneTime: 1_790_000_000.4), 1_790_000_000) + XCTAssertEqual(StrapClock.setSeconds(forPhoneTime: 1_790_000_000.5), 1_790_000_001) + XCTAssertEqual(StrapClock.setSeconds(forPhoneTime: 1_790_000_000.9), 1_790_000_001) + } + + /// W06-075's oracle: a set applied 2.3 s late, read on the next connect over a 0.1 s round trip, across 1,000 + /// stamp phases and 10 read phases. Truncating the stamp is judged off 7,543 times in 10,000; the nearest + /// second, 3,192. The rest is the late apply itself. (The read phase at a whole second gives ranges that end + /// exactly on −2 s; before W06-089 they did not count as off, and the counts were 6,792 and 2,443.) + func testARoundedStampIsReSetLessOftenAfterALateApply() { + func offCount(_ stamp: (Double) -> Double) -> Int { + var off = 0 + for i in 0..<1000 { + let at = 1_790_000_000.0 + Double(i) / 1000 + let offset = stamp(at) - (at + 2.3) + for j in 0..<10 { + let sent = 1_790_000_100.0 + Double(j) / 10 + let s = UInt32((sent + 0.05 + offset).rounded(.down)) + let v = StrapClock.judge(.init(originSequence: 1, result: 1, seconds: s), + receivedAt: sent + 0.1, roundTrip: 0.1) + if case .off = v { off += 1 } + } + } + return off + } + XCTAssertEqual(offCount { $0.rounded(.down) }, 7_543) + XCTAssertEqual(offCount { Double(StrapClock.setSeconds(forPhoneTime: $0)) }, 3_192) + } + + // MARK: Check + + private let now = 1_790_000_000.0 + private func reading(_ origin: UInt8, result: UInt8 = 1, seconds: UInt32? = 1_790_000_000) -> StrapClock.Reading { + .init(originSequence: origin, result: result, seconds: seconds) + } + + func testAnInSyncReadSettlesWithoutASet() { + var check = StrapClock.Check() + check.beginRead(sequence: 9, at: now) + guard case let .settle(v, rt) = check.receive(reading(9), at: now + 0.5, wallClock: now + 0.5) else { + return XCTFail() + } + XCTAssertFalse(v.needsSet) + XCTAssertEqual(rt, 0.5) + XCTAssertTrue(check.settled) + XCTAssertFalse(check.expire(), "a settled check must not expire into a blind set") + } + + func testAnOffReadAsksForASetAndItsReadbackIsReportedNotJudged() { + var check = StrapClock.Check() + check.beginRead(sequence: 9, at: now) + guard case let .set(v, _) = check.receive(reading(9, seconds: 1_789_999_990), at: now + 0.2, + wallClock: now + 0.2) else { + return XCTFail() + } + XCTAssertTrue(v.needsSet) + check.beginReadback(sequence: 11, at: now + 0.3) + XCTAssertEqual(check.receive(reading(11, seconds: 1_790_000_000), at: now + 1.3, wallClock: now + 1.3), + .readback(reading(11, seconds: 1_790_000_000), roundTrip: 1.0)) + // A second copy of the readback answers nothing in flight. + XCTAssertEqual(check.receive(reading(11), at: now + 2, wallClock: now + 2), .notOurs) + } + + func testPendingWaitsForTheAnswer() { + var check = StrapClock.Check() + check.beginRead(sequence: 9, at: now) + XCTAssertEqual(check.receive(reading(9, result: 2, seconds: nil), at: now + 0.1, wallClock: now + 0.1), + .pending) + XCTAssertFalse(check.settled) + guard case .settle = check.receive(reading(9), at: now + 0.6, wallClock: now + 0.6) else { return XCTFail() } + check.beginReadback(sequence: 10, at: now + 1) + XCTAssertEqual(check.receive(reading(10, result: 2, seconds: nil), at: now + 1.1, wallClock: now + 1.1), + .pending) + } + + /// The round trip comes from the monotonic clock and the reading is judged against the wall clock at the + /// reply, so the two may sit on different bases (W06-074). + func testTheVerdictUsesTheWallClockAtTheReplyAndTheMonotonicRoundTrip() { + var check = StrapClock.Check() + check.beginRead(sequence: 9, at: 500) // seconds since some monotonic origin + guard case let .settle(v, rt) = check.receive(reading(9), at: 500.25, wallClock: now + 0.25) else { + return XCTFail() + } + XCTAssertEqual(rt, 0.25) + XCTAssertEqual(v, .inSync(seconds: 1_790_000_000, low: -0.25, high: 1.0)) + } + + func testAReplyToAnotherRequestIsNotOurs() { + var check = StrapClock.Check() + check.beginRead(sequence: 9, at: now) + XCTAssertEqual(check.receive(reading(8), at: now + 0.2, wallClock: now + 0.2), .notOurs) + XCTAssertFalse(check.settled) + } + + func testAnUnansweredReadExpiresOnceAndALateReplyChangesNothing() { + var check = StrapClock.Check() + check.beginRead(sequence: 9, at: now) + XCTAssertTrue(check.expire()) + XCTAssertFalse(check.expire()) + XCTAssertEqual(check.receive(reading(9, seconds: 1_789_000_000), at: now + 12, wallClock: now + 12), + .notOurs) + check.beginRead(sequence: 12, at: now + 13) + XCTAssertNil(check.read, "a settled check starts no second read") + } + + func testAReadThatNeverWentOutStillExpires() { + var check = StrapClock.Check() + XCTAssertTrue(check.expire()) + } + + // MARK: Check, second read (W06-084) + + /// A read answered over a restored link's round trip cannot be judged, so the check reads once more, and + /// the second read decides. + func testAnUnresolvedReadOverALongRoundTripIsReadOnceMore() { + var check = StrapClock.Check() + check.beginRead(sequence: 9, at: 100) + guard case let .readAgain(first, rt) = check.receive(reading(9), at: 104, wallClock: now + 0.5) else { + return XCTFail() + } + XCTAssertEqual(rt, 4) + XCTAssertEqual(first, .unresolved(seconds: 1_790_000_000, low: -0.5, high: 4.5)) + XCTAssertFalse(check.settled) + XCTAssertEqual(check.standingVerdict, first) + check.beginRead(sequence: 10, at: 104.25) + guard case let .settle(second, _) = check.receive(reading(10), at: 104.5, wallClock: now + 0.75) else { + return XCTFail() + } + XCTAssertEqual(second, .inSync(seconds: 1_790_000_000, low: -0.75, high: 0.5)) + XCTAssertTrue(check.settled) + XCTAssertNil(check.standingVerdict) + } + + func testAStrapFourSecondsBehindOverALongRoundTripIsSetAfterTheSecondRead() { + var check = StrapClock.Check() + check.beginRead(sequence: 9, at: 100) + guard case .readAgain = check.receive(reading(9, seconds: 1_789_999_996), at: 104, wallClock: now + 0.5) else { + return XCTFail() + } + check.beginRead(sequence: 10, at: 104.25) + XCTAssertEqual(check.receive(reading(10, seconds: 1_789_999_996), at: 104.5, wallClock: now + 0.75), + .set(.off(seconds: 1_789_999_996, low: -4.75, high: -3.5), roundTrip: 0.25)) + } + + func testThereIsNoThirdRead() { + var check = StrapClock.Check() + check.beginRead(sequence: 9, at: 100) + guard case .readAgain = check.receive(reading(9), at: 104, wallClock: now + 0.5) else { return XCTFail() } + check.beginRead(sequence: 10, at: 104.25) + guard case let .settle(v, _) = check.receive(reading(10, seconds: 1_790_000_004), at: 108.25, + wallClock: now + 4.75) else { + return XCTFail() + } + XCTAssertEqual(v, .unresolved(seconds: 1_790_000_004, low: -0.75, high: 4.25)) + XCTAssertTrue(check.settled) + } + + /// Up to one second the range is at most two seconds wide, so an unresolved verdict there comes from the + /// clock, not the round trip, and a second read would not change it. + func testAnUnresolvedReadOverAShortRoundTripSettlesAtOnce() { + for (rt, readsAgain) in [(0.5, false), (1.0, false), (1.25, true)] { + var check = StrapClock.Check() + check.beginRead(sequence: 9, at: 100) + let step = check.receive(reading(9, seconds: 1_789_999_998), at: 100 + rt, wallClock: now + 0.5) + if case .readAgain = step { + XCTAssertTrue(readsAgain, "\(rt)") + } else if case .settle(.unresolved, _) = step { + XCTAssertFalse(readsAgain, "\(rt)") + } else { + XCTFail("\(rt): \(step)") + } + } + } + + /// A second read that goes unanswered leaves the first reading standing, and the caller settles on it + /// rather than setting the clock without a reading. + func testAnUnansweredSecondReadLeavesTheFirstVerdictStanding() { + var check = StrapClock.Check() + check.beginRead(sequence: 9, at: 100) + guard case let .readAgain(first, _) = check.receive(reading(9), at: 104, wallClock: now + 0.5) else { + return XCTFail() + } + check.beginRead(sequence: 10, at: 104.25) + XCTAssertTrue(check.expire()) + XCTAssertEqual(check.standingVerdict, first) + XCTAssertFalse(check.expire()) + } + + /// The review's oracle (W06-084), run through the check: first reads answered 0.9 s and 3.2–4.7 s after + /// they were sent (the strap reads its clock 50 ms before the reply lands), second reads 0.3 s, 100 + /// sub-second phases each. With one read, every case at 3.2–4.7 s ended unresolved. + func testASecondReadResolvesWhatARestoredLinksRoundTripCannot() { + func tally(offset: Double, firstRoundTrip rt1: Double) -> [String: Int] { + let rt2 = 0.3 + var out: [String: Int] = [:] + for i in 0..<100 { + let t0 = now + Double(i) / 100 + var check = StrapClock.Check() + check.beginRead(sequence: 9, at: 1000) + let s1 = UInt32((t0 + rt1 - 0.05 + offset).rounded(.down)) + var step = check.receive(reading(9, seconds: s1), at: 1000 + rt1, wallClock: t0 + rt1) + var reads = 1 + if case .readAgain = step { + reads = 2 + let t1 = t0 + rt1 + 0.01 + check.beginRead(sequence: 10, at: 1000 + rt1 + 0.01) + let s2 = UInt32((t1 + rt2 - 0.05 + offset).rounded(.down)) + step = check.receive(reading(10, seconds: s2), at: 1000 + rt1 + 0.01 + rt2, wallClock: t1 + rt2) + } + let verdict: String + switch step { + case .settle(.inSync, _): verdict = "inSync" + case .settle(.unresolved, _): verdict = "unresolved" + case .set(.off, _): verdict = "off" + default: verdict = "\(step)" + } + out["\(verdict)/\(reads)", default: 0] += 1 + } + return out + } + XCTAssertEqual(tally(offset: 0, firstRoundTrip: 0.9), ["inSync/1": 100]) + XCTAssertEqual(tally(offset: -4, firstRoundTrip: 0.9), ["off/1": 100]) + for rt1 in [3.2, 4.0, 4.7] { + XCTAssertEqual(tally(offset: 0, firstRoundTrip: rt1), ["inSync/2": 100], "\(rt1)") + // One phase lands exactly on −2 s, which an open range counts as off (W06-089; 74 and 26 before). + XCTAssertEqual(tally(offset: -3, firstRoundTrip: rt1), ["off/2": 75, "unresolved/2": 25], "\(rt1)") + XCTAssertEqual(tally(offset: -4, firstRoundTrip: rt1), ["off/2": 100], "\(rt1)") + XCTAssertEqual(tally(offset: -5, firstRoundTrip: rt1), ["off/2": 100], "\(rt1)") + } + } + + // MARK: describe + + /// W06-089: a printed range rounds outward, so it always holds the range it stands for, and a range printed + /// for `.off` cannot read as the same numbers as one printed for `.unresolved`. Rounded to nearest, a high + /// bound of −2.04 (off) and one of −1.96 (unresolved) both printed "-2.0". + func testPrintedRangesRoundOutward() { + XCTAssertEqual(StrapClock.describe(.off(seconds: 1, low: -3.54, high: -2.04), roundTrip: 0.5), + "strap clock reads 1, -3.6…-2.0 s from the phone over a 0.5 s round trip — " + + "more than 2 s off, setting it") + XCTAssertEqual(StrapClock.describe(.unresolved(seconds: 1, low: -3.46, high: -1.96), roundTrip: 0.5), + "strap clock reads 1, -3.5…-1.9 s from the phone over a 0.5 s round trip — " + + "that does not show it more than 2 s off, not set") + // Exact tenths stay put rather than creeping outward on floating-point noise, and zero prints unsigned. + XCTAssertEqual(StrapClock.describe(.inSync(seconds: 1, low: -0.3, high: -0.04), roundTrip: 0.1), + "strap clock reads 1, -0.3…+0.0 s from the phone over a 0.1 s round trip — within 2 s, not set") + } + + func testLogLinesStateTheReadingAndTheRange() { + XCTAssertEqual(StrapClock.describe(.inSync(seconds: 1_790_000_000, low: -0.65, high: 0.75), roundTrip: 0.4), + "strap clock reads 1790000000, -0.7…+0.8 s from the phone over a 0.4 s round trip — " + + "within 2 s, not set") + XCTAssertEqual(StrapClock.describe(.unresolved(seconds: 1_790_000_000, low: -4.25, high: 0.75), roundTrip: 4), + "strap clock reads 1790000000, -4.3…+0.8 s from the phone over a 4.0 s round trip — " + + "that does not show it more than 2 s off, not set") + XCTAssertEqual(StrapClock.describe(.off(seconds: 1_789_999_996, low: -4.75, high: -3.25), roundTrip: 0.5), + "strap clock reads 1789999996, -4.8…-3.2 s from the phone over a 0.5 s round trip — " + + "more than 2 s off, setting it") + XCTAssertEqual(StrapClock.describe(.invalid(seconds: 0), roundTrip: 0.1), + "strap clock reads 0, not a wall clock (at or below 1293840001) — setting it") + XCTAssertEqual(StrapClock.describe(.unread(result: 3), roundTrip: 0.1), + "GET_CLOCK answered result 3 with no reading — setting the clock") + XCTAssertEqual(StrapClock.describeReadAgain(.unresolved(seconds: 1_790_000_000, low: -0.5, high: 4.5), + roundTrip: 4), + "strap clock reads 1790000000, -0.5…+4.5 s from the phone over a 4.0 s round trip — " + + "too long to judge, reading it again") + } +} diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5CommandResponseTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5CommandResponseTests.swift index 35cdeedba5..0437f66f9f 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5CommandResponseTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5CommandResponseTests.swift @@ -46,10 +46,11 @@ final class Whoop5CommandResponseTests: XCTestCase { XCTAssertEqual(f.parsed["history_newest"]?.intValue, 1780926332) // 2026-06-08 } - /// SYNTHETIC GET_HELLO(145): fake device name "WHOOP-FAKE01" at pay[16], fw 50.38.1.0 at pay[93]; - /// everything else zeroed — no real name, no session token. + /// SYNTHETIC GET_HELLO(145): a SUCCESS (1) reply with fake device name "WHOOP-FAKE01" at pay[16], fw + /// 50.38.1.0 at pay[93]; everything else zeroed — no real name, no session token. Only a SUCCESS reply + /// carries the block (W06-087). private let helloHex = - "aa0174000001ffe12401910000000000000000000000000000000057484f4f502d46414b453031000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000032260100000000000000000000000000a30d90ed" + "aa0174000001ffe12401910001000000000000000000000000000057484f4f502d46414b4530310000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000322601000000000000000000000000001bcdda2b" func testHelloDeviceNameAndFirmware() { let f = parseFrame(bytes(helloHex), family: .whoop5) @@ -59,9 +60,10 @@ final class Whoop5CommandResponseTests: XCTestCase { XCTAssertEqual(f.parsed["fw_version"]?.stringValue, "50.38.1.0") } - /// SYNTHETIC all-zero GET_HELLO: the guards must fail closed — no device name, no firmware. + /// SYNTHETIC all-zero GET_HELLO with a SUCCESS (1) result, so the block reaches the guards: they must fail + /// closed — no device name, no firmware. private let helloZeroHex = - "aa0174000001ffe12402910000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000063ef7ada" + "aa0174000001ffe124029100010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000db2f301c" func testHelloGuardsFailClosed() { let f = parseFrame(bytes(helloZeroHex), family: .whoop5) diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 2c91c62c0a..9be1c5d354 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -915,8 +915,9 @@ public final class BLEManager: NSObject, ObservableObject { /// deliver, and the subscribe loops skip anything already `isNotifying` — so nothing re-arms and no live /// HR/R-R arrive, while `didUpdateNotificationStateFor` never fires (→ `cmdNotifyConfirmedActive` → /// `connectSettled` never latch). While this is true, `requestNotify` forces one real off→on - /// re-subscribe so delivery — and the settle/alarm-re-arm chain — is re-established. Cleared the moment - /// `connectSettled` bumps, and on disconnect. + /// re-subscribe so delivery — and the settle/alarm-re-arm chain — is re-established. Cleared once that + /// forced pass has run (the 5/MG handshake; the clear in `maybeSignalConnectSettled` serves only the WHOOP 4 + /// path, which NOOP no longer reaches), and on disconnect. private var restoreNeedsResubscribe = false /// Re-entrancy guard for captureRawAccel: true while a bounded on-demand window is running. /// A second tap is a no-op until the active capture's asyncAfter block fires and clears this. @@ -1077,7 +1078,8 @@ public final class BLEManager: NSObject, ObservableObject { /// Once-per-connection guard for the 5/MG offload kick (connectHandshakeDone + requestSync + /// startBackfillTimer). Stops the HISTORY_END acks re-entering didWriteValueFor from re-triggering /// the offload mid-stream (the 5/MG twin of the WHOOP4 connectHandshakeDone ack-storm guard). - private var whoop5SessionStarted = false + /// Internal for `StrapClockCheckTests`, which cannot run the handshake that sets it. + var whoop5SessionStarted = false /// Backfill ACKs can arrive hundreds or thousands of times in one offload. Keep the strap log /// readable and avoid forcing SwiftUI to auto-scroll on every ACK row. private var historicalAckLogCounter = 0 @@ -1111,6 +1113,19 @@ public final class BLEManager: NSObject, ObservableObject { private var clockRequested = false /// #700: retry count for GET_CLOCK when no correlation establishes before backfill. Capped at 3. private var clockRetries = 0 + /// W06-050: this link's read-first strap clock check (5/MG). Reset on disconnect. Internal so + /// `StrapClockCheckTests` can put a read in flight without a strap. + var strapClockCheck = StrapClock.Check() + /// Fences a previous link's clock-check timeout, so it can never settle the next link's handshake. + /// Readable by `StrapClockCheckTests`, which time out the check a second read re-arms. + private(set) var strapClockCheckToken = 0 + /// The `connectGeneration` of the link the clock check began on, so its timeout can tell that link from a + /// later one that came up without `didDisconnectPeripheral` (a Bluetooth power-off, W06-069). + private var strapClockCheckLink = 0 + /// When this link's 5/MG handshake ran, in `monotonicSeconds()`, so the first offload keeps its ~1.5 s settle + /// delay after it and no wall-clock step moves that (W06-086). Internal for `StrapClockCheckTests`, which + /// cannot run the handshake that sets it. + var whoop5HandshakeAt: Double? private var intentionalDisconnect = false /// Consecutive `didFailToConnect` count, for the auto-reconnect backoff (#414). Reset to 0 on a /// successful connect; grows the reschedule delay so a strap that's genuinely out of range doesn't @@ -2265,10 +2280,9 @@ public final class BLEManager: NSObject, ObservableObject { /// sites are unaffected. Pass `.withResponse` for acked commands (e.g. historicalDataResult). public func send(_ command: WhoopCommand, payload: [UInt8] = [0x00], writeType: CBCharacteristicWriteType = .withoutResponse) { - // #314 parity: CoreBluetooth already covers both Android defects here — this `p.state == .connected` - // guard makes a write a no-op once the radio powers off (no DeadObjectException to crash on), and - // centralManagerDidUpdateState publishes state.connected = false on .poweredOff, so the iOS/macOS UI - // can't show a stale-connected link. The Android fix re-creates both behaviours; no Swift change needed. + // #314 parity: this `p.state == .connected` guard makes a write a no-op once the radio powers off (no + // DeadObjectException to crash on). `state.connected` alone is not enough: a power-off reaches neither + // `didDisconnectPeripheral` nor any other write of it, so the flag outlives the link (W06-083). guard state.connected, let p = peripheral, p.state == .connected, let ch = cmdCharacteristic else { let reason = state.connected ? "command characteristic unavailable" : "not connected" log("send(\(command.label)) ignored — \(reason)") @@ -2293,10 +2307,12 @@ public final class BLEManager: NSObject, ObservableObject { // bodies are built at the call sites and pad4 covers their 20-/2-byte bodies), the two // historical-offload commands, and the clock pair. SEND_HISTORICAL_DATA triggers the // offload; HISTORICAL_DATA_RESULT acks each HISTORY_END to walk the trim cursor. - // SET_CLOCK/GET_CLOCK are MANDATORY before history: an un-clocked WHOOP 5 doesn't save + // A valid strap clock is MANDATORY before history: an un-clocked WHOOP 5 doesn't save // sensor data to flash at all ("RTC timestamp … is invalid; not saving data to flash"), // so offloads complete with zero body frames — hardware-validated, same 8-byte WHOOP4 - // payload over puffin framing. (#78 fork) + // payload over puffin framing (#78 fork). Each connect reads the clock with GET_CLOCK and + // sends SET_CLOCK only when the reply is refused or has no reading, the reading is invalid or + // more than 2 s off, or no reading comes in 10 s (W06-050). guard command == .toggleRealtimeHR || command == .runHapticsPattern || command == .setAlarmTime || command == .getAlarmTime || command == .runAlarm || command == .disableAlarm @@ -5192,7 +5208,8 @@ public final class BLEManager: NSObject, ObservableObject { // no longer delivers, and `setNotifyValue(true)` on an already-notifying char yields NO callback // (no state change). Force one real off→on cycle so delivery is re-established AND // `didUpdateNotificationStateFor` fires — the only path that latches `cmdNotifyConfirmedActive` - // → `connectSettled` → the alarm re-arm. One-shot: `restoreNeedsResubscribe` clears at settle. + // → `connectSettled` → the alarm re-arm. One-shot: `restoreNeedsResubscribe` clears once the + // forced pass has run (the 5/MG handshake). if restoreNeedsResubscribe { log("Notify re-arming after restore \(c.uuid) (\(reason))") p.setNotifyValue(false, for: c) @@ -5212,8 +5229,9 @@ public final class BLEManager: NSObject, ObservableObject { /// /// WHOOP 4.0 sequence: SET_CLOCK first to ensure the strap RTC is UTC-correct, then the /// rev-1 SET_ALARM_TIME. WHOOP 5/MG sends the REVISION_4 body alone — the strap maintains - /// its RTC (set during the connect handshake / history sync) and the official app's alarm - /// path doesn't re-set it (wire observation; mirrors Android WhoopBleClient.armStrapAlarm). + /// its RTC (the connect handshake reads it and sets it only when the reply is refused or has no + /// reading, the reading is invalid or more than 2 s off, or no reading comes in 10 s, W06-050) and + /// the official app's alarm path doesn't re-set it (wire observation). /// Either way the strap will buzz at `date` even if the app is backgrounded or force-quit /// (event STRAP_DRIVEN_ALARM_EXECUTED=57). This is the only alarm path: the strap fires at /// the fixed time — NOOP has no light-sleep early-wake layer. @@ -6083,6 +6101,9 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { whoop5SessionStarted = false clockRequested = false clockRetries = 0 + strapClockCheck = StrapClock.Check() + strapClockCheckToken &+= 1 + whoop5HandshakeAt = nil connectHandshakeDone = false // Mirrors the flag above: a new link has not done the handshake, so it cannot hand over history // until it does. Cleared HERE and nowhere else, so the two can never disagree. @@ -6303,7 +6324,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // #613: the inherited notify subscriptions come back reported-active but dead. Force one real // off→on re-subscribe this session (see `requestNotify`) so live HR/R-R resume AND // `didUpdateNotificationStateFor` fires → `cmdNotifyConfirmedActive` → `connectSettled` → the - // alarm re-arm. Cleared when `connectSettled` bumps. + // alarm re-arm. Cleared once the forced pass has run (the 5/MG handshake). restoreNeedsResubscribe = true log("Restored CONNECTED peripheral \(p.identifier) — re-discovering services") discoverPrimaryServices(on: p) @@ -6676,45 +6697,8 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // .withResponse ack during the offload (each HISTORY_END ack), so the trigger work MUST fire // once or it would re-issue SEND_HISTORICAL_DATA mid-stream and storm the strap. The notify // re-subscribe + realtime-arm above are idempotent and intentionally run on every re-entry; - // only this block is gated. `whoop5SessionStarted` resets on disconnect. - if !whoop5SessionStarted { - whoop5SessionStarted = true - connectHandshakeDone = true // unblocks beginBackfill()'s guard - state.historyReady = true // and the sync controls, which must not offer what it declines - log("WHOOP 5/MG: connect handshake done — backfill unblocked") - noteRebootReconnectIfNeeded() - // Re-apply the Broadcast-HR device-config flag if the user opted in (#181). - if PuffinExperiment.broadcastHrEnabled { setBroadcastHr(true) } - // Clock the strap BEFORE history: an un-clocked WHOOP 5 discards sensor data ("RTC - // timestamp … is invalid; not saving data to flash") and history offloads "succeed" - // with metadata only. Same 8-byte payload as the WHOOP4 handshake, puffin-framed; - // GET_CLOCK's reply rides the puffin notify chars and never touches the WHOOP4 - // clockRef correlation path. The 1.5s deferral below keeps clock-before-history. - // Hardware-validated ordering (#78 fork). - send(.setClock, payload: BLEManager.setClockPayload()) - send(.getClock, payload: []) - // #1823: say what was SENT, not what resulted. This previously read "clock synced — - // strap can persist history now", logged before any reply had arrived, so a log could - // assert the clock was set while the Devices readout said 1970/71. The strap's actual - // answer now arrives as the "clock: …" ack line from FrameRouter. - log("WHOOP 5/MG: SET_CLOCK + GET_CLOCK sent — awaiting the strap's answer") - log("WHOOP 5/MG: scheduling first historical offload (connect)") - // Deferred ~1.5s so the puffin notify subscriptions settle before SEND_HISTORICAL_DATA, - // mirroring the WHOOP4 kick. requestSync → beginBackfill is itself gated on - // connectHandshakeDone, so a racing foreground/restore trigger can't fire it early. - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.requestConnectSync() } - startBackfillTimer() // re-offload the type-47 store every backfillIntervalSeconds - // #34: signal settled directly (skip the cmd-notify gate `maybeSignalConnectSettled()` - // uses) — armStrapAlarm's 5/MG branch never sends GET_ALARM_TIME (log-only readback is - // WHOOP4-only, and the 5/MG alarm itself stays behind the Experimental toggle), so there's - // no reply-channel race to wait out here; this just keeps the re-arm-on-bond signal firing - // for 5/MG the way it did before `connectSettled` replaced raw `bonded`. - if !connectSettledSignaled { - connectSettledSignaled = true - state.connectSettled &+= 1 - restoreNeedsResubscribe = false // #613: forced re-subscribe pass is done (5/MG path) - } - } + // only `startWhoop5SessionIfNeeded` is gated. `whoop5SessionStarted` resets on disconnect. + startWhoop5SessionIfNeeded() return } @@ -6755,7 +6739,7 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { // ignores the empty form (#120). Send both — the strap answers whichever its firmware // accepts, and the `clockRef == nil` correlation guard makes a second reply a no-op. // Without the [0x00] form, correlation never establishes on 41.17.x, so a lost RTC (the - // 1971 clock behind #120) stays invisible and ClockPolicy can never re-fix it. Both + // 1971 clock behind #120) stays invisible and no drift check can re-fix it. Both // GET_CLOCKs ride behind both SET_CLOCKs above, so the reply reflects the corrected clock. // (Offload doesn't depend on this — Backfiller falls back to an identity clockRef — but a // real correlation drives realtime decode and the drift re-set.) @@ -6839,6 +6823,261 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { send(.setClock, payload: BLEManager.setClockPayload(now: now)) } + /// Seconds on a clock that no wall-clock step moves and that keeps counting while the device sleeps, for the + /// clock check's round trips (W06-074). Only differences between two readings mean anything. + static func monotonicSeconds() -> Double { + let c = (ContinuousClock.now - monotonicOrigin).components + return Double(c.seconds) + Double(c.attoseconds) / 1e18 + } + private static let monotonicOrigin = ContinuousClock.now + + /// `send`, returning the request sequence the frame carried, or nil when `send` wrote nothing. A reply + /// names its request by that sequence (format-1 byte 11). + private func sendReturningSequence(_ command: WhoopCommand, payload: [UInt8]) -> UInt8? { + let before = seq + send(command, payload: payload) + return seq == before ? nil : seq + } + + /// W06-050: read the 5/MG strap clock. Its reply (`handleStrapClockReply`) or the timeout settles the + /// check, and a SET_CLOCK follows only when the reply is refused or has no reading, or the reading is invalid + /// or provably more than `StrapClock.driftThresholdSeconds` off. A first reply over a round trip too long to + /// judge it is read once + /// more (W06-084). A strap that never answers is set without a reading after + /// `StrapClock.replyTimeoutSeconds`, as every connect did before, since an un-clocked strap banks nothing. + /// + /// Returns the check's token, which its timeout carries. Internal for `StrapClockCheckTests`. + @discardableResult + func beginStrapClockCheck() -> Int { + strapClockCheck = StrapClock.Check() + strapClockCheckLink = connectGeneration + // #1823: say what was SENT, not what resulted; the verdict line follows the strap's answer. W06-067: + // and only when it was sent. + if let sequence = sendReturningSequence(.getClock, payload: []) { + strapClockCheck.beginRead(sequence: sequence, at: Self.monotonicSeconds()) + // W06-090: the line says what was sent; the verdict line says whether a set follows and why. + clockReadout("WHOOP 5/MG: GET_CLOCK sent — its reply decides whether SET_CLOCK follows") + } else { + log("WHOOP 5/MG: GET_CLOCK was not sent, so the clock check waits out its " + + "\(Int(StrapClock.replyTimeoutSeconds)) s timeout") + } + return armStrapClockTimeout() + } + + /// Arm the clock check's reply timeout under a fresh token, which fences every earlier one. + @discardableResult + private func armStrapClockTimeout() -> Int { + strapClockCheckToken &+= 1 + let token = strapClockCheckToken + DispatchQueue.main.asyncAfter(deadline: .now() + StrapClock.replyTimeoutSeconds) { [weak self] in + self?.strapClockCheckTimedOut(token: token) + } + return token + } + + /// W06-084: the check's one second read, sent once the first came back over a round trip too long to judge + /// it, with a timeout of its own. + private func readStrapClockAgain() { + if let sequence = sendReturningSequence(.getClock, payload: []) { + strapClockCheck.beginRead(sequence: sequence, at: Self.monotonicSeconds()) + } else { + log("WHOOP 5/MG clock: the second GET_CLOCK was not sent, so the first reading waits out the " + + "\(Int(StrapClock.replyTimeoutSeconds)) s timeout") + } + armStrapClockTimeout() + } + + /// The 5/MG handshake's once-per-link work, run from the CLIENT_HELLO acknowledgement and a no-op on every + /// later write acknowledgement of the link. Internal for `StrapClockCheckTests` (W06-096), which cannot drive + /// `didWriteValueFor`. + func startWhoop5SessionIfNeeded() { + guard !whoop5SessionStarted else { return } + whoop5SessionStarted = true + whoop5HandshakeAt = Self.monotonicSeconds() + noteRebootReconnectIfNeeded() + // Re-apply the Broadcast-HR device-config flag if the user opted in (#181). + if PuffinExperiment.broadcastHrEnabled { setBroadcastHr(true) } + // Clock the strap BEFORE history: an un-clocked WHOOP 5 discards sensor data ("RTC + // timestamp … is invalid; not saving data to flash") and history offloads "succeed" + // with metadata only (#78 fork). W06-050: read the clock first and set it only when the + // reading says it is wrong. `connectHandshakeDone`, which the first offload, the sync + // controls and the alarm re-arm all wait on, is set when this check settles + // (`strapClockSettled`), so a SET_CLOCK it sends still precedes all three. + beginStrapClockCheck() + startBackfillTimer() // re-offload the type-47 store every backfillIntervalSeconds + restoreNeedsResubscribe = false // #613: forced re-subscribe pass is done (5/MG path) + } + + /// What the clock check's timeout finds of the link (W06-085): the one the check began on, still able to take + /// a command (W06-091); a newer one that came up without `didDisconnectPeripheral` (a Bluetooth power-off, + /// W06-083) and is past its CLIENT_HELLO, so it skipped the handshake; or neither. + enum ClockCheckLink: Equatable { case same, newer, none } + + /// The link as the clock check's timeout sees it. W06-069: a Bluetooth power-off does not end the link + /// through `didDisconnectPeripheral`, so the token outlives it and `state.connected` stays set (W06-083). + /// Ask the link itself, as `send` does, and whether it is still the one the check began on, since a + /// reconnect after a power-off bumps no token. + private var clockCheckLink: ClockCheckLink { + Self.classifyClockCheckLink(connected: state.connected, peripheralConnected: peripheral?.state == .connected, + sameGeneration: connectGeneration == strapClockCheckLink, + hasCommandCharacteristic: cmdCharacteristic != nil, + helloOutstanding: clientHelloWriteAt != nil) + } + + /// `clockCheckLink` from the link's facts, pure so `StrapClockCheckTests` can pin it without a peripheral + /// (W06-096). + nonisolated static func classifyClockCheckLink(connected: Bool, peripheralConnected: Bool, sameGeneration: Bool, + hasCommandCharacteristic: Bool, + helloOutstanding: Bool) -> ClockCheckLink { + guard connected, peripheralConnected else { return .none } + // W06-091: `send` needs the command characteristic too, so without it the set would be logged and dropped. + if sameGeneration { return hasCommandCharacteristic ? .same : .none } + // The command characteristic and the CLIENT_HELLO write come in one callback, so a newer link that has + // the characteristic and no hello outstanding had its hello acknowledged. One whose hello is still out + // counts as none: that acknowledgement runs the handshake once the session flag is clear. + return hasCommandCharacteristic && !helloOutstanding ? .newer : .none + } + + /// The clock check begun with `token` got no reply in `StrapClock.replyTimeoutSeconds`. A no-op when a + /// reply settled it or a later link replaced it. `link` stands in for what the manager sees of the link, for + /// `StrapClockCheckTests`, which cannot connect a peripheral. Internal for those tests. + func strapClockCheckTimedOut(token: Int, link: ClockCheckLink? = nil) { + guard token == strapClockCheckToken, strapClockCheck.expire() else { return } + // W06-090: "reading", not "reply": a strap that answered PENDING did reply. + let waited = "no GET_CLOCK reading within \(Int(StrapClock.replyTimeoutSeconds)) s" + switch link ?? clockCheckLink { + case .same: + if strapClockCheck.standingVerdict != nil { + // W06-084: the first read was answered and did not show the clock off, and a set without a + // reading could only undo that; its verdict stands. + log("WHOOP 5/MG clock: no reading from the second GET_CLOCK within " + + "\(Int(StrapClock.replyTimeoutSeconds)) s — the first reading stands, and it does not show the " + + "clock more than \(Int(StrapClock.driftThresholdSeconds)) s off; not set") + strapClockSettled(setJustSent: false) + } else { + log("WHOOP 5/MG clock: \(waited) — setting the clock without a reading") + setStrapClock() + strapClockSettled(setJustSent: true) + } + case .newer: + // W06-085: the link that replaced it inherited `whoop5SessionStarted` and skipped the handshake, so + // nothing has read its clock. It gets its own check, whose reply or timeout settles it on a live link. + log("WHOOP 5/MG clock: \(waited), and the link the check began on is gone — reading the clock on " + + "the link that replaced it") + beginStrapClockCheck() + case .none: + // W06-085: settle nothing on a link that cannot take commands, whether it is gone or, on the same + // link, has lost its command characteristic (W06-091). Settling here bumped `connectSettled`, which + // spent the alarm re-arm on a link whose sends are dropped, and left the reconnect, which inherits + // `whoop5SessionStarted` and skips the handshake, with no clock check at all. Clearing the flag lets + // the next CLIENT_HELLO acknowledgement run the whole handshake, check included. The check began with + // the handshake undone and nothing has settled since, so `connectHandshakeDone` and + // `connectSettledSignaled` are still clear. + whoop5SessionStarted = false + log("WHOOP 5/MG clock: \(waited), and no link is ready for the check's commands — nothing is set; " + + "the handshake, clock check included, runs again when a CLIENT_HELLO is next acknowledged") + } + } + + /// W06-050: fold one GET_CLOCK reply into this link's clock check and act on its verdict. Verified first, + /// since the verdict can send a SET_CLOCK. Internal for `StrapClockCheckTests`. + func handleStrapClockReply(_ frame: [UInt8]) { + guard verifyFrame(frame, family: .whoop5).ok, let reading = StrapClock.decodeReply(frame) else { return } + switch strapClockCheck.receive(reading, at: Self.monotonicSeconds(), wallClock: Date().timeIntervalSince1970) { + case .notOurs: + log("WHOOP 5/MG clock: a GET_CLOCK reply to request \(reading.originSequence) answers no read in " + + "flight — ignored") + case .pending: + log("WHOOP 5/MG clock: GET_CLOCK answered PENDING — waiting for the reading") + case let .settle(verdict, roundTrip): + let line = "WHOOP 5/MG clock: " + StrapClock.describe(verdict, roundTrip: roundTrip) + // W06-079: a clock in sync is what every connect expects; one the round trip could not resolve is + // rare evidence and stays always-on, like every verdict that sets. + if case .inSync = verdict { clockReadout(line) } else { log(line) } + strapClockSettled(setJustSent: false) + case let .set(verdict, roundTrip): + log("WHOOP 5/MG clock: " + StrapClock.describe(verdict, roundTrip: roundTrip)) + setStrapClock() + strapClockSettled(setJustSent: true) + case let .readAgain(verdict, roundTrip): + // W06-084: on a relaunch the first read waits behind the notify re-subscribe writes, so this is a + // routine readout, behind Test Centre like the in-sync verdict. The second read decides. + clockReadout("WHOOP 5/MG clock: " + StrapClock.describeReadAgain(verdict, roundTrip: roundTrip)) + readStrapClockAgain() + case let .readback(r, roundTrip): + let reads = r.seconds.map { "reads \($0)" } ?? "gave no reading" + log("WHOOP 5/MG clock: after the set the strap \(reads) (result \(r.result)), answered " + + String(format: "%.1f", roundTrip) + " s after the set was sent") + } + } + + /// A per-connect clock readout, logged only under Test Centre → Connection (W06-079): AGENTS.md keeps + /// routine per-connect lines out of a default log and leaves rare events, here any set, timeout or unusual + /// reply, always-on. + private func clockReadout(_ s: String) { + guard TestCentre.active(.connection) else { return } + state.append(log: "[\(timestamp())] \(s)", domain: .connection) + } + + /// Set the 5/MG strap clock to the phone's and read it back, so the log shows what latched. + private func setStrapClock() { + let seconds = StrapClock.setSeconds(forPhoneTime: Date().timeIntervalSince1970) + send(.setClock, payload: BLEManager.setClockPayload(now: seconds)) + if let sequence = sendReturningSequence(.getClock, payload: []) { + strapClockCheck.beginReadback(sequence: sequence, at: Self.monotonicSeconds()) + } + } + + /// How long after the clock check settles the first 5/MG offload is requested. The hardware-validated + /// order is SET_CLOCK, ~1.5 s, then SEND_HISTORICAL_DATA, and the same 1.5 s lets the puffin notify + /// subscriptions settle after the handshake, mirroring the WHOOP4 kick. So after a set the full 1.5 s runs + /// from the set (W06-071: counting it from the handshake gave a set sent after the 10 s timeout no gap at + /// all), and without one, whatever remains of it since the handshake, never more than the full 1.5 s + /// (W06-086: counted on the wall clock, a phone clock stepped back during the check postponed the offload by + /// the size of the step). The gap holds for this request only (W06-099): the settle opens the sync gate in + /// the same turn, so a strap EVENT, the Sync Strap shortcut or Sync now can ask for history sooner, as they + /// could before W06-050, and offloads that began about 1 s after a set have served data. + nonisolated static func firstOffloadDelay(sinceHandshake: TimeInterval?, setJustSent: Bool) -> TimeInterval { + let settle: TimeInterval = 1.5 + return setJustSent ? settle : min(settle, max(0, settle - (sinceHandshake ?? 0))) + } + + /// The 5/MG clock check has decided, so the connect handshake is done. The first offload, the sync + /// controls and the alarm re-arm unblock here, after any SET_CLOCK the check sent; `setJustSent` says + /// whether it sent one just now. + private func strapClockSettled(setJustSent: Bool) { + connectHandshakeDone = true // unblocks beginBackfill()'s guard + state.historyReady = true // and the sync controls, which must not offer what it declines + log("WHOOP 5/MG: connect handshake done — backfill unblocked") + // See `firstOffloadDelay`. requestSync → beginBackfill is itself gated on connectHandshakeDone, so a + // racing foreground/restore trigger can't fire it early. + let sinceHandshake = whoop5HandshakeAt.map { Self.monotonicSeconds() - $0 } + let delay = Self.firstOffloadDelay(sinceHandshake: sinceHandshake, setJustSent: setJustSent) + log("WHOOP 5/MG: scheduling first historical offload (connect) in " + String(format: "%.1f", delay) + " s") + // W06-086: the request belongs to this link. After a settle only a disconnect or a later link's check moves + // the token, and a request that ran on that later link would spend a parked Sync Strap request before its + // handshake is done. + let token = strapClockCheckToken + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + guard self.strapClockCheckToken == token else { + self.log("WHOOP 5/MG: first historical offload request dropped — the link it was scheduled on has " + + "ended") + return + } + self.requestConnectSync() + } + // #34: signal settled directly (skip the cmd-notify gate `maybeSignalConnectSettled()` uses) — + // armStrapAlarm's 5/MG branch never sends GET_ALARM_TIME (log-only readback is WHOOP4-only, and the + // 5/MG alarm itself stays behind the Experimental toggle), so there's no reply-channel race to wait + // out here; this just keeps the re-arm-on-bond signal firing for 5/MG the way it did before + // `connectSettled` replaced raw `bonded`. + if !connectSettledSignaled { + connectSettledSignaled = true + state.connectSettled &+= 1 + } + } + /// Newest plausible-unix marker in a GET_DATA_RANGE COMMAND_RESPONSE = the strap's newest stored /// record. Mirrors re/diagnose_biometrics.py: scan u32 LE words in the response body (data starts at /// frame[7], after [type,seq,cmd]), keep those in the unix range, return the max. nil if none. @@ -7190,6 +7429,10 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { handleBroadcastHrGateReadBack(frame, isWhoop5: true) } } + // W06-050: a GET_CLOCK reply decides whether this link sets the strap clock. Verified inside. + if frame.count > 10, frame[8] == 0x24, frame[10] == WhoopCommand.getClock.rawValue { + handleStrapClockReply(frame) + } // #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. @@ -7211,7 +7454,9 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { verdictOK: parseFrame(frame, family: .whoop5).ok) { // feedsSync: false — #695 diagnostic-only on 5/MG: log the dump/backlog/newest/oldest so // a strap log validates the decode, but DON'T feed strapNewestTs/backfill/state yet. - // Flip to true once a real 5.0/MG strap confirms the newest/oldest are correct. + // Flip to true once a real 5.0/MG strap confirms the newest/oldest are correct. W06-102: the flip also + // arms the stuck-strap watchdog (`checkStrapLiveness`), whose recovery sends a SET_CLOCK with no read, + // past W06-050's read-first check, so route that set through the check before flipping. handleDataRangeResponse(frame, cmdOff: 10, feedsSync: false) } // NOTE: we deliberately do NOT ingest live 5/MG REALTIME_DATA into the Collector diff --git a/Strand/BLE/FrameRouter.swift b/Strand/BLE/FrameRouter.swift index 2f3a39c459..c8a5d02021 100644 --- a/Strand/BLE/FrameRouter.swift +++ b/Strand/BLE/FrameRouter.swift @@ -178,22 +178,21 @@ public final class FrameRouter { // 1970/71 — two contradictory lines with nothing to separate them. Same accept/reject shape // REBOOT_STRAP already uses. LOG-ONLY; it never gates behaviour. if let cmd = parsed.cmdName, cmd.hasPrefix("SET_CLOCK") || cmd.hasPrefix("GET_CLOCK") { - // NO accept/reject verdict here, and that is deliberate: the result byte may not exist at - // all for this command. The captured-frame fixture builds a puffin COMMAND_RESPONSE as - // [36, seq, cmd] + payload at offset 8, so @11 is already PAYLOAD and the @12 that - // `commandResultByte` reads is a payload byte, not a result code. REBOOT_STRAP's use of it - // was validated against reboot's own frames; nothing establishes it for the clock. - // - // Inventing a verdict from that is precisely the fault this line was added to fix - the - // old "clock synced" log asserted an outcome nobody had checked. So quote the evidence - // and let a maintainer decode it: the byte at the result offset, and the WHOLE - // frame (#900's format), uncapped. A truncated clock frame answers nothing, and the full - // frame is what makes a wrong offset assumption visible instead of silently misleading. - let r = Self.commandResultByte(in: frame, family: family) - let rhex = r.map { String(format: "0x%02x", UInt8(truncatingIfNeeded: $0)) } ?? "none" - state.append(log: "clock: \(cmd) reply byte@resultOffset=\(rhex) " - + "frame=\(Self.fullFrameHex(frame))", - domain: .connection) + // W06-001: both replies carry the format-1 result at byte 12, and GET_CLOCK's carries the + // strap's seconds at 13 (`StrapClock.decodeReply`; 12 distinct replies from 50.39.1.0 agree). This + // line quotes the reply; BLEManager's "WHOOP 5/MG clock:" line judges it. The WHOLE frame + // (#900's format) stays, uncapped, so a moved offset on another firmware stays visible. + let result = parsed.parsed["result"]?.stringValue ?? "none" + let seconds = StrapClock.decodeReply(frame)?.seconds + let reading = seconds.map { " strap=\($0)" } ?? "" + // W06-100: a GET_CLOCK reply with a reading is every connect's routine readout, and BLEManager's + // verdict line says what it means, so it sits behind Test Centre like that line. A SET_CLOCK reply, + // which is how a default log shows every set (W06-079), and any reply without a reading stay on. + let routine = cmd.hasPrefix("GET_CLOCK") && seconds != nil + if !routine || TestCentre.active(.connection) { + state.append(log: "clock: \(cmd) result=\(result)\(reading) frame=\(Self.fullFrameHex(frame))", + domain: .connection) + } } // #1303: the hunt for a stable per-strap id, which is what multi-strap identity waits on — // the pack serial from cmd 151 identifies a REMOVABLE PART rather than the strap wearing it. @@ -203,14 +202,22 @@ public final class FrameRouter { // the serial is in there, it has been arriving all along. // // Reports STRUCTURE, not the block: the same response carries a session token the decoder - // deliberately never reads, so `HelloIdentityProbe` prints only serial-shaped runs and - // withholds the rest. Test Centre → Connection gated on top of that, so nothing here reaches a - // default (shareable) strap log. Log-only; decodes and persists nothing. + // deliberately never reads, so `HelloIdentityProbe` quotes only serial-shaped runs, and only their + // first three characters (W06-092), and withholds the rest. Test Centre → Connection gated on top + // of that, so none of it reaches a default strap log. Log-only; decodes and persists nothing. + // + // W06-066: only a SUCCESS reply carries the block; the PENDING acknowledgement that precedes it + // is all zeros and would report "none", which the probe reads as "no ASCII serial here". And this + // payload starts after the result byte (frame byte 13), two bytes later than the decoder's (frame + // byte 11), so the name offset comes from the decoder in this payload's coordinates (W06-076). if let cmd = parsed.cmdName, cmd.hasPrefix("GET_HELLO("), // Schema appends the raw value, e.g. "(145)" + parsed.parsed["result"]?.stringValue?.hasPrefix("SUCCESS") == true, TestCentre.active(.connection), let pay = Self.commandResponsePayload(in: frame, family: family) { - state.append(log: HelloIdentityProbe.report(payload: pay) + " — locate the strap serial (#1303)") + let nameOffset = HelloIdentityProbe.whoop5NameOffsetAfterResult + state.append(log: HelloIdentityProbe.report(payload: pay, knownNameOffset: nameOffset) + + " — offsets from frame byte 13; locate the strap serial (#1303)") } // The 5/MG battery pack (cmd 151). `BatteryPackInfo` has decoded this reply since its offsets // were captured, and until now nothing sent the command — so the decoder had no caller and the @@ -388,16 +395,17 @@ public final class FrameRouter { } /// The payload of a COMMAND_RESPONSE: the bytes after [type,seq,cmd,origin_seq,result] (payload - /// starts at inner+5) up to the crc32 trailer at `length`. nil when the frame is too short to carry - /// any payload. + /// starts at inner+5) up to the crc32 trailer, zero padding included. The trailer starts 4 bytes past + /// the declared length, the u16 LE at bytes 2–3 (W06-065: reading bytes 1–2 put it past every real + /// frame). nil when the frame is too short to carry any payload. nonisolated static func commandResponsePayload(in frame: [UInt8], family: DeviceFamily = .whoop5) -> [UInt8]? { _ = family - guard frame.count > 2 else { return nil } - let length = Int(frame[1]) | (Int(frame[2]) << 8) // crc32 starts here - let start = innerOffset + 5 // skip type,seq,cmd,origin_seq,result - guard length <= frame.count, start < length else { return nil } - return Array(frame[start..= 4 else { return nil } + let bodyEnd = 4 + (Int(frame[2]) | (Int(frame[3]) << 8)) // crc32 starts here + let start = innerOffset + 5 // skip type,seq,cmd,origin_seq,result + guard bodyEnd <= frame.count, start < bodyEnd else { return nil } + return Array(frame[start.. String { let ageDays = max(0, wallNowUnix - newestUnix) / 86_400 - return "Backfill: this sync banked nothing and the strap's newest stored record is about \(ageDays) day(s) old. If you have worn it since then, it has stopped saving history to its flash. NOOP already re-sends the clock on every connect, so charging alone may not be enough: charge to 100% and reconnect, then use Restart strap in Devices, and if that does not help forget and re-pair. If the official WHOOP app is also missing these days, the strap is the cause and not NOOP." + return "Backfill: this sync banked nothing and the strap's newest stored record is about \(ageDays) day(s) old. If you have worn it since then, it has stopped saving history to its flash. NOOP already checks the strap clock on every connect and sets it when it is wrong, so charging alone may not be enough: charge to 100% and reconnect, then use Restart strap in Devices, and if that does not help forget and re-pair. If the official WHOOP app is also missing these days, the strap is the cause and not NOOP." } @@ -544,15 +545,15 @@ final class Backfiller { /// The standing banner says "fully charge it to 100%, then reconnect, and it should start banking /// again". It omits the one fact that makes the situation legible - how long the strap has been /// silent - and it PROMISES a recovery that has already failed every session for weeks, because NOOP - /// re-sends SET_CLOCK on every connect and the charge advice has therefore been retried all along. A - /// banner that keeps promising something that keeps not happening teaches people to distrust the app - /// rather than their strap. + /// checks the clock on every connect and sets a wrong one, so the charge advice has been retried all + /// along. A banner that keeps promising something that keeps not happening teaches people to distrust + /// the app rather than their strap. /// - /// Byte-identical to the Android twin. Not localized, matching the sibling `lastSyncError` copy on - /// both platforms; localizing that surface is its own change. No em-dash (project rule). + /// Not localized, matching the sibling `lastSyncError` copy; localizing that surface is its own change. + /// No em-dash (project rule). The upstream Android twin still says NOOP re-sets the clock every connect. nonisolated static func staleRecordBanner(newestUnix: Int, wallNowUnix: Int) -> String { let ageDays = max(0, wallNowUnix - newestUnix) / 86_400 - return "Synced, but your strap handed over no stored history, and its newest saved record is about \(ageDays) day(s) old. If you have been wearing it since then, it has stopped saving to flash. Charge it to 100% and reconnect; NOOP already re-sets its clock every connect, so if that does not help, try Restart strap in Devices, then forget and re-pair. If the official WHOOP app is missing these days too, the strap is the cause and not NOOP." + return "Synced, but your strap handed over no stored history, and its newest saved record is about \(ageDays) day(s) old. If you have been wearing it since then, it has stopped saving to flash. Charge it to 100% and reconnect; NOOP already checks its clock every connect and sets it when it is wrong, so if that does not help, try Restart strap in Devices, then forget and re-pair. If the official WHOOP app is missing these days too, the strap is the cause and not NOOP." } /// #1754: the banner for an empty offload whose flash cursor is VALID and ADVANCING — the strap is diff --git a/Strand/Collect/ClockPolicy.swift b/Strand/Collect/ClockPolicy.swift deleted file mode 100644 index e500afe75b..0000000000 --- a/Strand/Collect/ClockPolicy.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -/// Pure decision: should we SET_CLOCK on this connect? Mirrors WHOOP — set only when the strap's RTC -/// has drifted beyond a small threshold (or is frozen/way off), not blindly every connect. Avoids -/// gratuitous clock resets that could introduce discontinuities in derived historical timestamps. -enum ClockPolicy { - /// `deviceClock` = the strap's current RTC reading (unix seconds, from GET_CLOCK). `wallNow` = - /// phone wall time (unix seconds). Returns true if |drift| >= threshold. - static func shouldSetClock(deviceClock: Int, wallNow: Int, driftThreshold: Int = 2) -> Bool { - abs(wallNow - deviceClock) >= driftThreshold - } -} diff --git a/StrandTests/BackfillerSessionTallyTests.swift b/StrandTests/BackfillerSessionTallyTests.swift index 13f00e6596..67ccb266ab 100644 --- a/StrandTests/BackfillerSessionTallyTests.swift +++ b/StrandTests/BackfillerSessionTallyTests.swift @@ -279,8 +279,9 @@ final class BackfillerSessionTallyTests: XCTestCase { let line = Backfiller.staleRecordLine(newestUnix: 1_785_692_420, wallNowUnix: 1_787_820_941) XCTAssertTrue(line.contains("about 24 day(s) old"), line) XCTAssertTrue(line.contains("stopped saving history"), line) - // The part the old advice omitted: charging alone has already been retried every connect. - XCTAssertTrue(line.contains("re-sends the clock on every connect"), line) + // The part the old advice omitted: charging alone has already been retried every connect. Since + // W06-050 a connect sets the clock only when its reading is wrong, and the copy says so. + XCTAssertTrue(line.contains("checks the strap clock on every connect and sets it when it is wrong"), line) // The test that tells the user whether NOOP is even involved. XCTAssertTrue(line.contains("official WHOOP app"), line) XCTAssertFalse(line.contains("\u{2014}")) @@ -296,7 +297,7 @@ final class BackfillerSessionTallyTests: XCTestCase { /// The banner is what the user READS; the log line needs a capture export. The standing banner /// omitted the age entirely and PROMISED that charging "should" work - advice NOOP has effectively - /// retried on every connect for weeks, since it re-sends SET_CLOCK each time. + /// retried on every connect for weeks, since every connect checks the clock and sets a wrong one. func testStaleRecordBannerDatesTheSilenceAndPromisesNothing() { let line = Backfiller.staleRecordBanner(newestUnix: 1_785_692_420, wallNowUnix: 1_787_820_941) XCTAssertTrue(line.contains("about 24 day(s) old"), line) diff --git a/StrandTests/FrameRouterCommandPayloadTests.swift b/StrandTests/FrameRouterCommandPayloadTests.swift new file mode 100644 index 0000000000..ca4d825dce --- /dev/null +++ b/StrandTests/FrameRouterCommandPayloadTests.swift @@ -0,0 +1,132 @@ +import XCTest +@testable import Strand +import WhoopProtocol + +/// W06-065: `commandResponsePayload` must end the payload where the declared length (bytes 2–3) puts the +/// CRC32 trailer. It read bytes 1–2, which on every real 5/MG reply pointed past the frame and returned +/// nil, so the GET_HELLO identity probe (#1303), its only caller, never printed. The two frames are real +/// replies from a WHOOP MG on firmware 50.39.1.0. +@MainActor +final class FrameRouterCommandPayloadTests: XCTestCase { + + private func bytes(_ s: String) -> [UInt8] { + stride(from: 0, to: s.count, by: 2).map { i in + let a = s.index(s.startIndex, offsetBy: i) + return UInt8(s[a...s.index(a, offsetBy: 1)], radix: 16)! + } + } + + func testARealGetClockReplyYieldsItsBodyUpToTheTrailer() { + let frame = bytes("aa011400010021b124230be80146aaaf6a0000000000000014cbbb6c") + XCTAssertEqual(FrameRouter.commandResponsePayload(in: frame), + [0x46, 0xaa, 0xaf, 0x6a, 0, 0, 0, 0, 0, 0, 0]) + XCTAssertEqual(FrameRouter.commandResponsePayloadHex(in: frame), "46 aa af 6a 00 00 00 00 00 00 00") + } + + func testARealSetClockReplyYieldsItsPadding() { + let frame = bytes("aa010c000100271124220ae701000000bcc4efc3") + XCTAssertEqual(FrameRouter.commandResponsePayload(in: frame), [0, 0, 0]) + } + + // MARK: - W06-066: the #1303 hello probe W06-065 revived + + /// A GET_HELLO reply shaped like the real 5/MG block: origin sequence and result, then a body in which + /// the decoder's offset 16 (frame byte 27) holds the device name and a serial-shaped run sits later. + /// Synthetic, so no real name, serial or session token enters the repository. + private func helloReply(result: UInt8) -> [UInt8] { + var body = [UInt8](repeating: 0, count: 110) + if result == 1 { + for (i, c) in "WHOOP-FAKE01".utf8.enumerated() { body[14 + i] = c } + for (i, c) in "3A1B2405003655".utf8.enumerated() { body[40 + i] = c } + } + return puffinCommandFrame(cmd: 145, seq: 0x6d, payload: [0x01, result] + body, type: 36, + header: [0x01, 0x00]) + } + + private func probeLines(after frame: [UInt8]) -> [String] { + TestCentre.activate(.connection) + defer { TestCentre.deactivate(.connection) } + let live = LiveState() + FrameRouter(state: live).handle(frame: frame) + return live.log.filter { $0.contains("#1303") } + } + + func testTheHelloProbeLabelsTheNameWhereTheDecoderReadsIt() { + let frame = helloReply(result: 1) + XCTAssertEqual(parseFrame(frame, family: .whoop5).parsed["device_name"]?.stringValue, "WHOOP-FAKE01", + "the fixture puts the name where the decoder reads it") + let lines = probeLines(after: frame) + XCTAssertEqual(lines.count, 1) + XCTAssertTrue(lines.first?.contains("off=14 len=12 mixed (device name, already decoded)") == true, + lines.first ?? "nil") + // W06-092: the serial-shaped run is quoted by its first three characters only. + XCTAssertTrue(lines.first?.contains(#"off=40 len=14 alnum "3A1…""#) == true, lines.first ?? "nil") + XCTAssertFalse(lines.first?.contains("3A1B2405003655") == true, lines.first ?? "nil") + // W06-103: the line says where its offsets count from, two bytes after the decoder's `pay`. + XCTAssertTrue(lines.first?.contains("offsets from frame byte 13") == true, lines.first ?? "nil") + } + + func testTheHelloProbeIgnoresThePendingAcknowledgement() { + XCTAssertEqual(probeLines(after: helloReply(result: 2)), [], + "a PENDING reply carries no block, so it must not report one without a serial") + } + + func testAFrameShorterThanItsDeclaredLengthHasNoPayload() { + let frame = bytes("aa011400010021b124230be80146aaaf6a0000000000000014cbbb6c") + XCTAssertNil(FrameRouter.commandResponsePayload(in: Array(frame.dropLast(5)))) + XCTAssertNil(FrameRouter.commandResponsePayload(in: [0xAA, 0x01, 0x14])) + } + + // MARK: - W06-095: a clock reply states a reading only when it carries one + + func testAFailedClockReplyPrintsNoReading() { + let frame = puffinCommandFrame(cmd: 11, seq: 0x40, payload: [0x07, 0x00] + [UInt8](repeating: 0, count: 11), + type: 36, header: [0x01, 0x00]) + let live = LiveState() + FrameRouter(state: live).handle(frame: frame) + let clock = live.log.filter { $0.contains("clock: GET_CLOCK") } + XCTAssertEqual(clock.count, 1, live.log.joined(separator: "\n")) + XCTAssertFalse(clock.first?.contains("strap=") == true, clock.first ?? "nil") + } + + // MARK: - W06-100: the routine GET_CLOCK reading is a Test Centre readout + + /// Runs `body` with Test Centre's connection domain set as given and the master flag, which implies every + /// domain, off. Both keys live in the test host's defaults, so they are restored afterwards. + private func withTestCentreConnection(_ on: Bool, _ body: () -> Void) { + let keys = ["testcentre.active.connection", "testcentre.active.master"] + let saved = keys.map { UserDefaults.standard.object(forKey: $0) } + defer { + for (key, value) in zip(keys, saved) { + if let value { UserDefaults.standard.set(value, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) } + } + } + UserDefaults.standard.removeObject(forKey: keys[1]) + if on { TestCentre.activate(.connection) } else { TestCentre.deactivate(.connection) } + body() + } + + private func clockLines(after frame: [UInt8]) -> [String] { + let live = LiveState() + FrameRouter(state: live).handle(frame: frame) + return live.log.filter { $0.contains("clock: ") } + } + + func testARoutineClockReadingIsATestCentreReadout() { + let reading = puffinCommandFrame(cmd: 11, seq: 0x40, + payload: [0x07, 0x01, 0x80, 0x0b, 0xb0, 0x6a] + [UInt8](repeating: 0, count: 7), + type: 36, header: [0x01, 0x00]) + withTestCentreConnection(false) { XCTAssertEqual(clockLines(after: reading), []) } + withTestCentreConnection(true) { XCTAssertEqual(clockLines(after: reading).count, 1) } + } + + /// Rare evidence stays in a default log: every SET_CLOCK reply (W06-079), and any clock reply without a reading. + func testASetClockReplyAndAReplyWithoutAReadingStayAlwaysOn() { + withTestCentreConnection(false) { + XCTAssertEqual(clockLines(after: bytes("aa010c000100271124220ae701000000bcc4efc3")).count, 1) + let failed = puffinCommandFrame(cmd: 11, seq: 0x40, payload: [0x07, 0x00] + [UInt8](repeating: 0, count: 11), + type: 36, header: [0x01, 0x00]) + XCTAssertEqual(clockLines(after: failed).count, 1) + } + } +} diff --git a/StrandTests/StrapClockCheckTests.swift b/StrandTests/StrapClockCheckTests.swift new file mode 100644 index 0000000000..7b508d6423 --- /dev/null +++ b/StrandTests/StrapClockCheckTests.swift @@ -0,0 +1,346 @@ +import XCTest +import WhoopProtocol +@testable import Strand + +/// W06-050's app-layer half (W06-073): what `BLEManager` does with each clock-check outcome. `StrapClockTests` +/// pins the verdicts; these pin that a verdict needing no set sends none, that the connect handshake waits for +/// the check, that a set settles it, that the timeout settles only the link the check began on (W06-085), and +/// that a first read answered over a round trip too long to judge is read once more (W06-084). +/// The manager has no strap, so `send` writes nothing and logs "send(