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
14 changes: 9 additions & 5 deletions ATTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,15 @@ This is long-standing practice, not a one-off. Worked examples already in the tr
field order, widths and enum values for `FilteredLabradorPacket` / `RawLabradorPacket`, plus the
`{revision, arg, padding}` command payload shape, re-derived from static analysis of the vendor's iOS
client. The four command NUMBERS were already in our own `CommandNumber` table from the whoomp/goose
work above. Everything ships as an **unvalidated candidate**: no strap has yet been asked whether it
honours these commands, the decode backs no metric, and the fields nobody can attest (the wrist enum's
raw values, the progress "timed out" sentinel, the ECG sample unit, the packet type byte) are carried
raw rather than named. The on-strap rhythm classifier's verdict is decoded as a byte and is never
presented as a finding — NOOP is not a medical device.
work above. Most of it still ships as an **unvalidated candidate**: the decode backs no metric, and the
fields nobody can attest (the wrist enum's raw values, the progress "timed out" sentinel, the ECG
sample unit, the packet type byte) are carried raw rather than named. **One exception is now attested
rather than inferred:** `ControlSignal`'s raw values were also read off the client's enum order, and a
WHOOP MG (`WS50_r00`, fw `50.39.1.0`) contradicts that reading — `1` stops generation, `2` starts it,
`0` is refused. The enum now carries the hardware values with the device and firmware named. A
hardware-observed value is strictly stronger than the inference it replaces, but it is one device.
The on-strap rhythm classifier's verdict is decoded as a byte and is never presented as a finding —
NOOP is not a medical device.

And the line held from the other side:

Expand Down
27 changes: 22 additions & 5 deletions Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift
Original file line number Diff line number Diff line change
Expand Up @@ -285,16 +285,31 @@ public enum Whoop5Ecg {
}

/// The `mainControlECGDataGeneration` argument.
///
/// ⚠️ These raw values are ATTESTED ON ONE DEVICE, and they are NOT the vendor client's declaration
/// order. The previous `stop = 0 / start = 1 / restart = 2` was read off that order and shipped
/// unverified (#896). On a WHOOP MG (`WS50_r00`, fw `50.39.1.0`) each argument was sent on its own
/// while watching the type-43 stream rather than the ack:
///
/// - `0` is REFUSED — the strap answers `FAILURE(0)` and generation is unchanged, so there is no
/// case for it here and nothing can send it.
/// - `1` STOPS generation.
/// - `2` STARTS it. The type-43 stream only follows once `TOGGLE_LABRADOR_FILTERED (139)` is on, so
/// the working turn-on order is `139 = 1` then `124 = 2`. 139 gates the STREAM rather than the
/// front end: with 139 closed, `124 = 2` still made the strap's own console log
/// `MAX86176: Set ECG ON` while no packets arrived (8 sends, 8 console lines, #891).
///
/// One device, one firmware. #1100 ran `WS50_r03` / `50.40.1.0` and nothing here says the two agree.
/// Whether `2` is a plain start or a stop-then-start is NOT distinguishable from a stream that was
/// already off, so the case is named for what it achieves rather than for the client's third name.
public enum ControlSignal: UInt8, Equatable, Sendable, CaseIterable {
case stop = 0
case start = 1
case restart = 2
case stop = 1
case start = 2

public var token: String {
switch self {
case .stop: return "stop"
case .start: return "start"
case .restart: return "restart"
}
}
}
Expand Down Expand Up @@ -367,7 +382,9 @@ public enum Whoop5Ecg {
case toggleRealtimeFilteredEcgCmd:
return arg != 0
case mainControlEcgDataGenerationCmd:
return arg == ControlSignal.start.rawValue || arg == ControlSignal.restart.rawValue
// Only the START value. `ControlSignal.stop` (1) halts generation, so a run whose last act
// was that one asked for nothing and its silence is the expected outcome, not a finding.
return arg == ControlSignal.start.rawValue
default:
return false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,9 +400,8 @@ final class Whoop5EcgTests: XCTestCase {
XCTAssertEqual(Whoop5Ecg.selectWristPayload(.left), [0x01, 0x01])
XCTAssertEqual(Whoop5Ecg.togglePayload(on: true), [0x01, 0x01])
XCTAssertEqual(Whoop5Ecg.togglePayload(on: false), [0x01, 0x00])
XCTAssertEqual(Whoop5Ecg.controlPayload(.stop), [0x01, 0x00])
XCTAssertEqual(Whoop5Ecg.controlPayload(.start), [0x01, 0x01])
XCTAssertEqual(Whoop5Ecg.controlPayload(.restart), [0x01, 0x02])
XCTAssertEqual(Whoop5Ecg.controlPayload(.stop), [0x01, 0x01])
XCTAssertEqual(Whoop5Ecg.controlPayload(.start), [0x01, 0x02])
}

func testCommandFramesAreExactlyWhatTheSendPathBuilds() {
Expand All @@ -415,7 +414,11 @@ final class Whoop5EcgTests: XCTestCase {
puffinCommandFrame(cmd: 0x8B, seq: seq, payload: [0x01, 0x01]))
XCTAssertEqual(Whoop5Ecg.toggleSaveRawEcgFrame(on: false, seq: seq),
puffinCommandFrame(cmd: 0x7D, seq: seq, payload: [0x01, 0x00]))
// LITERAL wire bytes, not `ControlSignal.start.rawValue`. Asserting through the symbol is what
// let the previous mapping stay green through a renumber: the test moved with the enum.
XCTAssertEqual(Whoop5Ecg.mainControlEcgDataGenerationFrame(.start, seq: seq),
puffinCommandFrame(cmd: 0x7C, seq: seq, payload: [0x01, 0x02]))
XCTAssertEqual(Whoop5Ecg.mainControlEcgDataGenerationFrame(.stop, seq: seq),
puffinCommandFrame(cmd: 0x7C, seq: seq, payload: [0x01, 0x01]))
}

Expand All @@ -442,8 +445,11 @@ final class Whoop5EcgTests: XCTestCase {
}

func testOffPathIsTheExactInverseOfTheOnPath() {
// The UI promises an explicit OFF path; these are the bytes it sends.
XCTAssertEqual(Whoop5Ecg.mainControlEcgDataGenerationFrame(.stop, seq: 1)[12], 0)
// The UI promises an explicit OFF path; these are the bytes it sends. The two toggles turn off
// with arg 0, but the generation stop is arg 1 — on hardware 0 is REFUSED (FAILURE(0)), and 1 is
// the verb that halts the stream. See the ControlSignal doc block. The old uniform-zero
// expectation here was written from the enum-order mapping this file no longer carries.
XCTAssertEqual(Whoop5Ecg.mainControlEcgDataGenerationFrame(.stop, seq: 1)[12], 1)
XCTAssertEqual(Whoop5Ecg.toggleRealtimeFilteredEcgFrame(on: false, seq: 1)[12], 0)
XCTAssertEqual(Whoop5Ecg.toggleSaveRawEcgFrame(on: false, seq: 1)[12], 0)
}
Expand Down Expand Up @@ -495,10 +501,14 @@ final class Whoop5EcgTests: XCTestCase {
XCTAssertFalse(Whoop5Ecg.requestsRealtimeData(cmd: Whoop5Ecg.toggleRealtimeFilteredEcgCmd, arg: 0))
XCTAssertTrue(Whoop5Ecg.requestsRealtimeData(cmd: Whoop5Ecg.mainControlEcgDataGenerationCmd,
arg: Whoop5Ecg.ControlSignal.start.rawValue))
XCTAssertTrue(Whoop5Ecg.requestsRealtimeData(cmd: Whoop5Ecg.mainControlEcgDataGenerationCmd,
arg: Whoop5Ecg.ControlSignal.restart.rawValue))
XCTAssertFalse(Whoop5Ecg.requestsRealtimeData(cmd: Whoop5Ecg.mainControlEcgDataGenerationCmd,
arg: Whoop5Ecg.ControlSignal.stop.rawValue))
// The same assertions again on LITERAL arguments. Through the symbol alone, a renumber moves
// the test with the enum and the predicate stays green whatever it now means on the wire —
// which is how `124 = 1` was scored as "asked for data" while it stopped generation.
XCTAssertTrue(Whoop5Ecg.requestsRealtimeData(cmd: 124, arg: 2))
XCTAssertFalse(Whoop5Ecg.requestsRealtimeData(cmd: 124, arg: 1))
XCTAssertFalse(Whoop5Ecg.requestsRealtimeData(cmd: 124, arg: 0))
// SELECT_WRIST configures which wrist; it starts nothing, on EITHER argument. This is the
// opcode whose silence was being reported as a firmware block.
for wrist in Whoop5Ecg.WristSelection.allCases {
Expand All @@ -515,21 +525,22 @@ final class Whoop5EcgTests: XCTestCase {
// The packet count comes from a HEURISTIC that ordinary traffic can trip; the result codes are
// attested wire semantics. So a firmware FAILURE must not be overridden by candidate frames —
// the old precedence turned one loose match into an unhedged "not blocked".
let failed = [sent(124, arg: 1, .failure)]
// arg 2, not 1: on hardware `124 = 2` is the one that asks for generation.
let failed = [sent(124, arg: 2, .failure)]
XCTAssertEqual(Whoop5EcgProbe.verdict(steps: failed, ecgPacketsSeen: 12, windowSeconds: 30),
.dataRequestRefused(commands: ["TOGGLE_LABRADOR_DATA_GENERATION(124)"]))
let unsupported = [sent(139, arg: 1, .unsupported)]
XCTAssertEqual(Whoop5EcgProbe.verdict(steps: unsupported, ecgPacketsSeen: 12, windowSeconds: 30),
.opcodeUnsupported(commands: ["TOGGLE_LABRADOR_FILTERED(139)"]))
// With no contrary result code, candidates are the verdict — as candidates, not as proof.
let ok = [sent(124, arg: 1, .success)]
let ok = [sent(124, arg: 2, .success)]
XCTAssertEqual(Whoop5EcgProbe.verdict(steps: ok, ecgPacketsSeen: 12, windowSeconds: 30),
.ecgCandidatesArrived(packets: 12))
}

func testCandidateVerdictIsHedgedNotAssertedAsProof() {
let text = Whoop5EcgProbe.report(
steps: [sent(124, arg: 1, .success)],
steps: [sent(124, arg: 2, .success)],
ecgPacketsSeen: 3, candidateFrames: ["type=0x28 len=220"], windowSeconds: 30)
XCTAssertTrue(text.contains("CANDIDATE, not proof"))
// The old wording asserted the conclusion outright; it must not come back.
Expand Down
28 changes: 25 additions & 3 deletions android/app/src/main/java/com/noop/protocol/Whoop5Ecg.kt
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,29 @@ object Whoop5Ecg {
RIGHT(0, "right"), LEFT(1, "left")
}

/** The mainControlECGDataGeneration argument. */
/**
* The mainControlECGDataGeneration argument.
*
* ⚠️ These raw values are ATTESTED ON ONE DEVICE, and they are NOT the vendor client's declaration
* order. The previous `STOP(0) / START(1) / RESTART(2)` was read off that order and shipped
* unverified (#896). On a WHOOP MG (`WS50_r00`, fw `50.39.1.0`) each argument was sent on its own
* while watching the type-43 stream rather than the ack:
*
* - `0` is REFUSED — the strap answers `FAILURE(0)` and generation is unchanged, so there is no
* case for it here and nothing can send it.
* - `1` STOPS generation.
* - `2` STARTS it. The type-43 stream only follows once `TOGGLE_LABRADOR_FILTERED (139)` is on, so
* the working turn-on order is `139 = 1` then `124 = 2`. 139 gates the STREAM rather than the
* front end: with 139 closed, `124 = 2` still made the strap's own console log
* `MAX86176: Set ECG ON` while no packets arrived (8 sends, 8 console lines, #891).
*
* One device, one firmware. #1100 ran `WS50_r03` / `50.40.1.0` and nothing here says the two agree.
* Whether `2` is a plain start or a stop-then-start is NOT distinguishable from a stream that was
* already off, so the case is named for what it achieves rather than for the client's third name.
* Twin of Swift `ControlSignal`.
*/
enum class ControlSignal(val raw: Int, val token: String) {
STOP(0, "stop"), START(1, "start"), RESTART(2, "restart")
STOP(1, "stop"), START(2, "start")
}

fun commandPayload(arg: Int): List<Int> = listOf(COMMAND_REVISION, arg)
Expand Down Expand Up @@ -229,7 +249,9 @@ object Whoop5Ecg {
fun requestsRealtimeData(cmd: Int, arg: Int): Boolean = when (cmd) {
TOGGLE_REALTIME_FILTERED_ECG_CMD -> arg != 0
MAIN_CONTROL_ECG_DATA_GENERATION_CMD ->
arg == ControlSignal.START.raw || arg == ControlSignal.RESTART.raw
// Only the START value. `ControlSignal.STOP` (1) halts generation, so a run whose last act
// was that one asked for nothing and its silence is the expected outcome, not a finding.
arg == ControlSignal.START.raw
else -> false
}

Expand Down
18 changes: 10 additions & 8 deletions android/app/src/test/java/com/noop/protocol/Whoop5EcgProbeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,18 @@ class Whoop5EcgProbeTest {
Whoop5Ecg.ControlSignal.START.raw,
),
)
assertTrue(
Whoop5Ecg.requestsRealtimeData(
Whoop5Ecg.MAIN_CONTROL_ECG_DATA_GENERATION_CMD,
Whoop5Ecg.ControlSignal.RESTART.raw,
),
)
assertFalse(
Whoop5Ecg.requestsRealtimeData(
Whoop5Ecg.MAIN_CONTROL_ECG_DATA_GENERATION_CMD,
Whoop5Ecg.ControlSignal.STOP.raw,
),
)
// The same three assertions again on LITERAL arguments. Through the symbol alone, a renumber
// moves the test with the enum and the predicate stays green whatever it now means on the wire —
// which is how `124 = 1` was scored as "asked for data" while it stopped generation.
assertTrue(Whoop5Ecg.requestsRealtimeData(124, 2))
assertFalse(Whoop5Ecg.requestsRealtimeData(124, 1))
assertFalse(Whoop5Ecg.requestsRealtimeData(124, 0))
// SELECT_WRIST configures which wrist; it starts nothing, on EITHER argument. This is the opcode
// whose silence was being reported as a firmware block.
for (wrist in Whoop5Ecg.WristSelection.entries) {
Expand All @@ -102,17 +102,19 @@ class Whoop5EcgProbeTest {

@Test
fun attestedResultCodesOutrankTheShapeHeuristic() {
// arg 2, not 1: on hardware `124 = 2` is the one that asks for generation. Written literally
// rather than through `ControlSignal.START.raw` so the fixture states the wire byte it means.
assertEquals(
Whoop5EcgProbe.Verdict.DataRequestRefused(listOf("TOGGLE_LABRADOR_DATA_GENERATION(124)")),
Whoop5EcgProbe.verdict(listOf(sent(124, 1, Whoop5EcgProbe.CommandOutcome.Failure)), 12, 30),
Whoop5EcgProbe.verdict(listOf(sent(124, 2, Whoop5EcgProbe.CommandOutcome.Failure)), 12, 30),
)
assertEquals(
Whoop5EcgProbe.Verdict.OpcodeUnsupported(listOf("TOGGLE_LABRADOR_FILTERED(139)")),
Whoop5EcgProbe.verdict(listOf(sent(139, 1, Whoop5EcgProbe.CommandOutcome.Unsupported)), 12, 30),
)
assertEquals(
Whoop5EcgProbe.Verdict.EcgCandidatesArrived(12),
Whoop5EcgProbe.verdict(listOf(sent(124, 1, Whoop5EcgProbe.CommandOutcome.Success)), 12, 30),
Whoop5EcgProbe.verdict(listOf(sent(124, 2, Whoop5EcgProbe.CommandOutcome.Success)), 12, 30),
)
}

Expand Down
11 changes: 6 additions & 5 deletions android/app/src/test/java/com/noop/protocol/Whoop5EcgTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -405,9 +405,10 @@ class Whoop5EcgTest {
assertEquals(listOf(0x01, 0x01), Whoop5Ecg.selectWristPayload(Whoop5Ecg.WristSelection.LEFT))
assertEquals(listOf(0x01, 0x01), Whoop5Ecg.togglePayload(on = true))
assertEquals(listOf(0x01, 0x00), Whoop5Ecg.togglePayload(on = false))
assertEquals(listOf(0x01, 0x00), Whoop5Ecg.controlPayload(Whoop5Ecg.ControlSignal.STOP))
assertEquals(listOf(0x01, 0x01), Whoop5Ecg.controlPayload(Whoop5Ecg.ControlSignal.START))
assertEquals(listOf(0x01, 0x02), Whoop5Ecg.controlPayload(Whoop5Ecg.ControlSignal.RESTART))
// LITERAL wire bytes, not `ControlSignal.X.raw`. Asserting through the symbol is what let the
// previous mapping stay green through a renumber: the test moved with the enum.
assertEquals(listOf(0x01, 0x01), Whoop5Ecg.controlPayload(Whoop5Ecg.ControlSignal.STOP))
assertEquals(listOf(0x01, 0x02), Whoop5Ecg.controlPayload(Whoop5Ecg.ControlSignal.START))
}

@Test
Expand All @@ -432,8 +433,8 @@ class Whoop5EcgTest {
val cases = listOf(
Whoop5Ecg.toggleRealtimeFilteredEcgFrame(on = true, seq = 9) to (0x8B to 1),
Whoop5Ecg.toggleSaveRawEcgFrame(on = false, seq = 9) to (0x7D to 0),
Whoop5Ecg.mainControlEcgDataGenerationFrame(Whoop5Ecg.ControlSignal.START, seq = 9) to (0x7C to 1),
Whoop5Ecg.mainControlEcgDataGenerationFrame(Whoop5Ecg.ControlSignal.STOP, seq = 9) to (0x7C to 0),
Whoop5Ecg.mainControlEcgDataGenerationFrame(Whoop5Ecg.ControlSignal.START, seq = 9) to (0x7C to 2),
Whoop5Ecg.mainControlEcgDataGenerationFrame(Whoop5Ecg.ControlSignal.STOP, seq = 9) to (0x7C to 1),
Whoop5Ecg.selectWristFrame(Whoop5Ecg.WristSelection.RIGHT, seq = 9) to (0x7B to 0),
)
for ((frame, expected) in cases) {
Expand Down
18 changes: 14 additions & 4 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,9 +459,19 @@ mapping as unconfirmed — the 5/MG is known to remap opcodes into the high spac
at 145/146/147 there versus 10/11 on a 4.0), so a code that is accepted is not evidence that it means
what the name says.

What is confirmed: on a real WHOOP 5 MG (`WS50_r03`), 124, 125 and 139 are all **accepted** — each
answers `COMMAND_RESPONSE` with result `SUCCESS(1)` — and no ECG-shaped data followed in a 30-second
window. That is a null result with several live explanations (an open electrode circuit, flash rather
The turn-on ORDER and the 124 argument are attested on one device. On a WHOOP MG (`WS50_r00`, fw
`50.39.1.0`), 139 gates the **stream**: with it off nothing arrives, so the working sequence is
**`139 = 1` then `124 = 2`**, after which type-43 carries a ~100 Hz single-channel i16 waveform,
present only while both clasp electrodes are held. 139 does not appear to gate the front end itself —
with 139 closed, `124 = 2` still made the strap's own `CONSOLE_LOGS` report `MAX86176: Set ECG ON`
while no packets arrived (eight sends, eight console lines, correlated on the strap's own uptime;
#891). Both directions are reversible (`124 = 1` or `139 = 0` stop the stream, both `SUCCESS`);
disconnecting also clears it. One device, one firmware — see the ⚠️ on `ControlSignal`.

What is confirmed on the other device: on a real WHOOP 5 MG (`WS50_r03`), 124, 125 and 139 are all
**accepted** — each answers `COMMAND_RESPONSE` with result `SUCCESS(1)` — and no ECG-shaped data
followed in a 30-second window. Those runs used `124 = 1` as their start verb, which under the mapping
above stops generation. That is a null result with several live explanations (an open electrode circuit, flash rather
than a realtime channel, a wrong opcode mapping, no start verb, a flag block, an entitlement gate); see
#891. The three reply frames are pinned as decode fixtures in `Whoop5CommandResponseTests` /
`CommandCatalogueTest`.
Expand Down Expand Up @@ -813,7 +823,7 @@ Three reasons the numbers are **not** settled, all of which the on-hardware prob
| Code | Command | Arg | Reversible? |
|-----:|---------|-----|---|
| 123 (0x7B) | `SELECT_WRIST` | `0` right / `1` left — **inferred from enum order, unconfirmed** | **Persistent device config** — survives disconnect; re-writable |
| 124 (0x7C) | `TOGGLE_LABRADOR_DATA_GENERATION` | `0` stop / `1` start / `2` restart | yes — `stop` is the OFF path |
| 124 (0x7C) | `TOGGLE_LABRADOR_DATA_GENERATION` | `1` stop / `2` start — `0` is REFUSED (`FAILURE(0)`, generation unchanged). Attested on one MG (`WS50_r00`, fw `50.39.1.0`); the earlier `0`/`1`/`2` reading came from the client's enum order | yes — `124 = 1` is the OFF path, and `139 = 0` also stops it |
| 125 (0x7D) | `TOGGLE_LABRADOR_RAW_SAVE` | `0`/`1` | yes |
| 139 (0x8B) | `TOGGLE_LABRADOR_FILTERED` | `0`/`1` | yes |

Expand Down
Loading