From e4675d3380a37cae02fdf1725cee8ae6db4fa595 Mon Sep 17 00:00:00 2001 From: Zebsi235 Date: Sat, 29 Aug 2026 10:18:41 +0200 Subject: [PATCH 1/2] ecg: correct the 124 argument mapping to the hardware values The vendor client's enum order shipped as stop=0/start=1/restart=2 in #896, explicitly unverified. On a WHOOP MG (WS50_r00, fw 50.39.1.0) each argument sent alone shows 0 is refused (FAILURE), 1 stops generation and 2 starts it; the strap's own console logs 'MAX86176: Set ECG ON' once per 124=2, eight for eight. The enum now carries the attested values, RESTART is gone (0 is not a valid argument, nothing may send it), and requestsRealtimeData accepts only START so a stop is never scored as having asked for data. Tests assert literal wire bytes on both platforms so a renumber can never move the fixtures with it. --- ATTRIBUTION.md | 14 ++++++---- .../Sources/WhoopProtocol/Whoop5Ecg.swift | 27 ++++++++++++++---- .../WhoopProtocolTests/Whoop5EcgTests.swift | 24 ++++++++++------ .../main/java/com/noop/protocol/Whoop5Ecg.kt | 28 +++++++++++++++++-- .../com/noop/protocol/Whoop5EcgProbeTest.kt | 18 ++++++------ .../java/com/noop/protocol/Whoop5EcgTest.kt | 11 ++++---- docs/PROTOCOL.md | 18 +++++++++--- 7 files changed, 102 insertions(+), 38 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index dbd6f5563c..a84b289370 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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: diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift index 18f7038c37..0a674be35b 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift @@ -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" } } } @@ -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 } diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgTests.swift index 043904808e..919cff7168 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgTests.swift @@ -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() { @@ -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])) } @@ -495,10 +498,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 { @@ -515,21 +522,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. diff --git a/android/app/src/main/java/com/noop/protocol/Whoop5Ecg.kt b/android/app/src/main/java/com/noop/protocol/Whoop5Ecg.kt index f1435a5cf8..f686b088f1 100644 --- a/android/app/src/main/java/com/noop/protocol/Whoop5Ecg.kt +++ b/android/app/src/main/java/com/noop/protocol/Whoop5Ecg.kt @@ -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 = listOf(COMMAND_REVISION, arg) @@ -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 } diff --git a/android/app/src/test/java/com/noop/protocol/Whoop5EcgProbeTest.kt b/android/app/src/test/java/com/noop/protocol/Whoop5EcgProbeTest.kt index ab1f288dba..6dcdbb844f 100644 --- a/android/app/src/test/java/com/noop/protocol/Whoop5EcgProbeTest.kt +++ b/android/app/src/test/java/com/noop/protocol/Whoop5EcgProbeTest.kt @@ -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) { @@ -102,9 +102,11 @@ 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)")), @@ -112,7 +114,7 @@ class Whoop5EcgProbeTest { ) 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), ) } diff --git a/android/app/src/test/java/com/noop/protocol/Whoop5EcgTest.kt b/android/app/src/test/java/com/noop/protocol/Whoop5EcgTest.kt index c8961c0033..faadbfd3ac 100644 --- a/android/app/src/test/java/com/noop/protocol/Whoop5EcgTest.kt +++ b/android/app/src/test/java/com/noop/protocol/Whoop5EcgTest.kt @@ -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 @@ -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) { diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 2927329ba2..975a09fed4 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -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`. @@ -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 | From a9ea6387d7f9294bd54b07a5bb6c03bf24b7ddf7 Mon Sep 17 00:00:00 2001 From: Zebsi235 Date: Sat, 29 Aug 2026 10:41:05 +0200 Subject: [PATCH 2/2] ecg: the off-path test expects the attested stop byte testOffPathIsTheExactInverseOfTheOnPath pinned all three OFF frames to arg 0, written from the enum-order mapping. The generation stop is arg 1 on hardware (0 is refused); the two toggles still turn off with 0. This test is Swift-only with no Kotlin twin, which is why the Android runs could not catch it - exactly the declared gap, caught by CI as intended. --- .../Tests/WhoopProtocolTests/Whoop5EcgTests.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgTests.swift index 919cff7168..f6076bb7f2 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5EcgTests.swift @@ -445,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) }