Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
264abe1
fix(ble): decode the 5/MG GET_CLOCK reply
ayiskakov Sep 26, 2026
9e219b6
fix(ble): read the 5/MG strap clock before setting it
ayiskakov Sep 26, 2026
5c842b9
fix(ble): read a command response's payload up to its declared length
ayiskakov Sep 26, 2026
4a77cbb
fix(ble): run the hello probe on the answer, at the decoder's name of…
ayiskakov Sep 26, 2026
c899af4
fix(ble): say when the clock check sent nothing or found the link down
ayiskakov Sep 26, 2026
29763a3
docs(ble): correct the clock batch's counts and stale text
ayiskakov Sep 26, 2026
e2d2845
docs(review): record the clock batch's code review as W06-069 … W06-083
ayiskakov Sep 26, 2026
9593293
test(ble): pin what the app does with each clock-check outcome
ayiskakov Sep 26, 2026
07f889f
fix(ble): let the clock-check timeout set only the link it was sent on
ayiskakov Sep 26, 2026
60e5a25
fix(ble): keep 1.5 s between a SET_CLOCK and the first offload
ayiskakov Sep 26, 2026
04938de
fix(ble): time the clock read on a monotonic clock
ayiskakov Sep 26, 2026
dfbb0b1
fix(ble): stamp a 5/MG SET_CLOCK with the nearest second
ayiskakov Sep 26, 2026
a1a6ae8
fix(ble): judge the strap clock against the threshold its log states
ayiskakov Sep 26, 2026
aef6288
fix(ble): take the hello probe's name offset from the decoder
ayiskakov Sep 26, 2026
92ff6c5
fix(ble): keep the routine clock-check lines behind Test Centre
ayiskakov Sep 26, 2026
bbf8b96
docs(ble): correct four clock and power-off comments
ayiskakov Sep 26, 2026
7898a02
docs(tools): say when whoop_setclock skips the write by default
ayiskakov Sep 26, 2026
b1318f2
docs(review): log the clock batch's code-review fixes
ayiskakov Sep 26, 2026
9290c70
docs(review): record the clock batch's second code review
ayiskakov Sep 26, 2026
2f29e4d
fix(ble): settle no clock check on a link that is gone
ayiskakov Sep 26, 2026
20c3f65
fix(ble): read the strap clock once more after a slow first reply
ayiskakov Sep 26, 2026
d111702
fix(ble): time the first offload on the monotonic clock, for its link
ayiskakov Sep 26, 2026
b12e031
fix(ble): quote only a serial's first three characters in the hello p…
ayiskakov Sep 26, 2026
a801e7a
test(ble): pin the clock check's dispatch, link test and handshake call
ayiskakov Sep 26, 2026
e7b34f8
fix(whoop5): decode the GET_HELLO block only from a SUCCESS reply
ayiskakov Sep 26, 2026
5dcb28e
docs(ble): give the 50.38.1.0 GET_CLOCK claim its source
ayiskakov Sep 26, 2026
29b69bb
fix(ble): read GET_CLOCK seconds only from a SUCCESS reply
ayiskakov Sep 26, 2026
1413b2f
fix(ble): put the routine GET_CLOCK reading behind Test Centre
ayiskakov Sep 26, 2026
63d9eb0
fix(ble): say where the hello probe's offsets count from
ayiskakov Sep 26, 2026
8749f6b
fix(ble): judge the clock range as the open interval it is
ayiskakov Sep 26, 2026
5fb7a7d
fix(ble): count the check's own link as up only when it can take comm…
ayiskakov Sep 26, 2026
d6b3321
fix(ble): state the clock-set rule whole, and say "no reading" after …
ayiskakov Sep 26, 2026
d15ced8
test(ble): pin decodeReply's guards at the exact body edges
ayiskakov Sep 26, 2026
8429ed2
docs(tools): correct whoop_setclock's reference clock and the WHOOP 4…
ayiskakov Sep 26, 2026
0e694c0
docs(ble): say the 1.5 s set-to-offload gap holds for the connect's o…
ayiskakov Sep 26, 2026
3d78c77
test(ble): isolate the clock-check tests from the host's defaults and…
ayiskakov Sep 26, 2026
2d3478e
docs(ble): warn at the data-range flip that it arms a blind SET_CLOCK
ayiskakov Sep 26, 2026
9d2990e
docs(review): log the second clock review's fixes and deferrals
ayiskakov Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Int> = 6...20) -> [String] {
var out: [String] = []
Expand All @@ -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)"
}
Expand All @@ -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<Int> = 6...20) -> String {
let lines = candidateLines(payload: payload, knownNameOffset: knownNameOffset,
Expand Down
18 changes: 13 additions & 5 deletions Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading