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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
512 changes: 449 additions & 63 deletions Packages/WhoopProtocol/Sources/WhoopProtocol/FeatureFlagProbe.swift

Large diffs are not rendered by default.

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2822,9 +2822,18 @@ public final class BLEManager: NSObject, ObservableObject {
switch FeatureFlagProbe.parseStart(frame: frame, family: family) {
case .success(let r):
featureFlagReport?.noteStart(r)
sendFeatureFlagStep(.sendNextFeatureFlag)
// A firmware that REFUSED 117 has nothing to enumerate; sending 118 anyway would spend a
// round-trip to learn what the refusal already said. `hasStopped` is the report's own
// named reason, so the driver never has to re-derive one.
if featureFlagReport?.hasStopped == true {
finishFeatureFlagProbe()
} else {
sendFeatureFlagStep(.sendNextFeatureFlag)
}
case .failure(let f):
featureFlagReport?.noteFailure(f, command: Int(awaiting))
// Pass the frame: a reply that failed to decode is the one whose RAW bytes matter most,
// and a report that kept only the parsed fields cannot be re-examined later.
featureFlagReport?.noteFailure(f, command: Int(awaiting), frame: frame)
finishFeatureFlagProbe()
}
return
Expand Down
13 changes: 11 additions & 2 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3489,9 +3489,18 @@ class WhoopBleClient(
val start = parsed.value
if (start != null) {
featureFlagReport?.noteStart(start)
sendFeatureFlagStep(CommandNumber.SEND_NEXT_FF)
// A firmware that REFUSED 117 has nothing to enumerate; sending 118 anyway would spend a
// round-trip to learn what the refusal already said. hasStopped is the report's own named
// reason, so the driver never has to re-derive one.
if (featureFlagReport?.hasStopped == true) {
finishFeatureFlagProbe()
} else {
sendFeatureFlagStep(CommandNumber.SEND_NEXT_FF)
}
} else {
featureFlagReport?.noteFailure(parsed.failure!!, awaiting)
// Pass the frame: a reply that failed to decode is the one whose RAW bytes matter most,
// and a report that kept only the parsed fields cannot be re-examined later.
featureFlagReport?.noteFailure(parsed.failure!!, awaiting, frame)
finishFeatureFlagProbe()
}
return
Expand Down
584 changes: 514 additions & 70 deletions android/app/src/main/java/com/noop/protocol/FeatureFlagProbe.kt

Large diffs are not rendered by default.

492 changes: 465 additions & 27 deletions android/app/src/test/java/com/noop/protocol/FeatureFlagProbeTest.kt

Large diffs are not rendered by default.

19 changes: 15 additions & 4 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,10 +547,21 @@ the same `pay[2]` record start `GET_BATTERY_LEVEL` and `GET_CLOCK` already decod
| 117 `START_FF_KEY_EXCHANGE` | `revision u8` · `numberOfFeatureFlags u16 LE` · padding |
| 118 `SEND_NEXT_FF` | `revision u8` · `index u8` · `validKey u8` · `key` (ASCII, NUL-terminated) · padding |

The walk stops on the strap's own end marker (`validKey = 0`, or `index = 0xFF`), on the announced count,
or on a hard cap of 128 replies — and each 118 is only sent after the previous reply lands. Both CRCs are
verified before any field is read; a failed CRC, a non-COMMAND_RESPONSE type, or a short record ends the
walk with a named reason instead of a decode. Driven by `BLEManager.probeFeatureFlags()` /
**The two terminator conditions are not interchangeable, and are separated deliberately.** The walk stops
on `index = 0xFF` — the one end marker a strap has served here unambiguously. `validKey = 0` on its own
does NOT stop it: that could equally mark an EMPTY or RETIRED SLOT with the list continuing past it, and
the record layout above is derived from a WHOOP 4.0 and **unverified on 5/MG**. Neither reading is
established, because on the walks this project has, the two have never been separated on the wire: the
117/118 walk on a WS50_r03 served sixteen replies that were all `validKey = 1` with no `0xFF` at all,
and its 115/116 walk ended on a single reply carrying `index = 255` **and** `validKey = 0` together. So a
`validKey = 0` entry is recorded, stepped over, and the next record verb is sent again — what comes back
separates the two readings, and the report states which it observed. Past that the bounds are all
CLIENT-side and each names itself in the report's `Stop code:` line: 8 consecutive `validKey = 0` replies,
a repeated index during such a run (a parked cursor — evidence for the terminator reading), the announced
count plus 4, or a hard cap of 128 replies. Each next-record request is only sent after the previous reply
lands. Both CRCs are verified before any field is read; a failed CRC, a non-COMMAND_RESPONSE type, or a
short record ends the walk with a named reason instead of a decode, and the RAW record bytes of every
reply are logged beside the fields decoded from them. Driven by `BLEManager.probeFeatureFlags()` /
`WhoopBleClient.probeFeatureFlags()` (user-triggered, Test Centre → Connection, both families) and
allowlisted for 5/MG framing **only while a probe is in flight**; parsed + rendered by the pure
`FeatureFlagProbe` / `FeatureFlagProbeReport` twins (Swift↔Kotlin byte-parity, unit-tested on synthetic
Expand Down