diff --git a/.github/workflows/source-hygiene.yml b/.github/workflows/source-hygiene.yml index 2c3673d8e0..9495ac9c76 100644 --- a/.github/workflows/source-hygiene.yml +++ b/.github/workflows/source-hygiene.yml @@ -45,3 +45,10 @@ jobs: # workflow or runner of its own. Fails on any assertion, so a doc edit cannot break it silently. - name: Protocol doc examples (arithmetic check) run: python3 docs/protocol-examples/validate_examples.py + + # docs/PROTOCOL_IMPLEMENTATION.md names client code as file plus symbol, without line numbers. + # Those references rot silently when code moves or is renamed: nobody re-reads the documentation + # during a Swift rename. The stdlib-only existence check covers both file and symbol in under a + # second, so it runs beside the validator on every PR rather than behind a docs/** filter. + - name: Protocol doc source references (existence check) + run: python3 docs/protocol-examples/check_source_references.py diff --git a/.github/workflows/tools-python.yml b/.github/workflows/tools-python.yml index 5467862993..1479de962d 100644 --- a/.github/workflows/tools-python.yml +++ b/.github/workflows/tools-python.yml @@ -75,7 +75,7 @@ jobs: # Parity governance has its own path-filtered workflow because that suite scans the repository and # is intentionally much slower. Keep this core floor independent so growth in parity cannot hide - # accidental deletion here. + # accidental deletion here; these six source-reference checks raise it to 114. - name: Run core Tools/ tests run: | set -o pipefail @@ -84,8 +84,8 @@ jobs: python3 -m unittest -v "${modules[@]}" 2>&1 | tee "$RUNNER_TEMP/out.txt" ran=$(grep -oE '^Ran [0-9]+ test' "$RUNNER_TEMP/out.txt" | grep -oE '[0-9]+') echo "collected ${ran:-0} tests" - if [ "${ran:-0}" -lt 108 ]; then - echo "::error::expected at least 108 core Tools tests, collected ${ran:-0} — discovery is broken, not the suite" + if [ "${ran:-0}" -lt 114 ]; then + echo "::error::expected at least 114 core Tools tests, collected ${ran:-0} — discovery is broken, not the suite" exit 1 fi working-directory: Tools diff --git a/Tools/test_check_source_references.py b/Tools/test_check_source_references.py new file mode 100644 index 0000000000..43e59e7314 --- /dev/null +++ b/Tools/test_check_source_references.py @@ -0,0 +1,143 @@ +"""Tests for protocol-document source reference validation.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CHECKER_PATH = REPO_ROOT / "docs/protocol-examples/check_source_references.py" +SPEC = importlib.util.spec_from_file_location("check_source_references", CHECKER_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"cannot load source-reference checker from {CHECKER_PATH}") +check_source_references = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(check_source_references) + + +class SourceReferenceTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.docs = self.root / "docs" + self.sources = self.root / "Sources" + self.docs.mkdir() + self.sources.mkdir() + + def tearDown(self) -> None: + self.temp.cleanup() + + def write(self, relative: str, text: str) -> Path: + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + def run_doc(self, doc: Path) -> tuple[int, list[str]]: + output = io.StringIO() + with contextlib.redirect_stdout(output): + code = check_source_references.main([str(doc)]) + return code, output.getvalue().splitlines() + + def test_valid_symbol_description_and_backtick_references(self) -> None: + self.write("Sources/Client.swift", "func decode(value: Int) {}\n") + self.write("Sources/support.py", "HELP_TEXT = 'protocol support'\n") + self.write("Sources/config.json", "{}\n") + doc = self.write( + "docs/PROTOCOL.md", + """[Client.decode(value:)](../Sources/Client.swift) +[implementation source](../Sources/support.py) +`../Sources/config.json` +""", + ) + + code, lines = self.run_doc(doc) + + self.assertEqual(0, code) + self.assertEqual(["checked 3 references, 3 files, 0 failures"], lines) + + def test_missing_symbol_reports_line_and_failure(self) -> None: + self.write("Sources/Client.swift", "func available() {}\n") + doc = self.write( + "docs/PROTOCOL.md", + "Intro\n[Client.missing()](../Sources/Client.swift)\n", + ) + + code, lines = self.run_doc(doc) + + self.assertEqual(1, code) + self.assertEqual(2, len(lines)) + self.assertEqual( + f"FAIL {doc}:2 ../Sources/Client.swift [missing] symbol not found", + lines[0], + ) + self.assertEqual("checked 1 references, 1 files, 1 failures", lines[1]) + + def test_missing_link_target_reports_file_failure(self) -> None: + doc = self.write( + "docs/PROTOCOL.md", + "[missing implementation](../Sources/Missing.swift)\n", + ) + + code, lines = self.run_doc(doc) + + self.assertEqual(1, code) + self.assertEqual( + f"FAIL {doc}:1 ../Sources/Missing.swift file does not exist", + lines[0], + ) + self.assertEqual("checked 1 references, 1 files, 1 failures", lines[1]) + + def test_missing_backtick_target_reports_file_failure(self) -> None: + doc = self.write("docs/PROTOCOL.md", "Use `../Sources/missing.py` for details.\n") + + code, lines = self.run_doc(doc) + + self.assertEqual(1, code) + self.assertEqual( + f"FAIL {doc}:1 ../Sources/missing.py file does not exist", + lines[0], + ) + self.assertEqual("checked 1 references, 1 files, 1 failures", lines[1]) + + def test_qualified_signature_extracts_final_symbol_and_matches_whole_words(self) -> None: + self.assertEqual( + "method", + check_source_references.symbol_from_text("A.B.method(x:y:)"), + ) + self.write("Sources/Client.swift", "func decodeAll() {}\n") + doc = self.write( + "docs/PROTOCOL.md", + "[A.B.decode(x:y:)](../Sources/Client.swift)\n", + ) + + code, lines = self.run_doc(doc) + + self.assertEqual(1, code) + self.assertEqual( + f"FAIL {doc}:1 ../Sources/Client.swift [decode] symbol not found", + lines[0], + ) + self.assertEqual("checked 1 references, 1 files, 1 failures", lines[1]) + + def test_repository_protocol_document_passes_smoke_check(self) -> None: + result = subprocess.run( + [sys.executable, str(CHECKER_PATH), str(REPO_ROOT / "docs/PROTOCOL_IMPLEMENTATION.md")], + cwd=REPO_ROOT, + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertIn("0 failures", result.stdout.splitlines()[-1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index cbdc3d2666..9dd2b6f764 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -2,15 +2,7 @@ An interoperability reference for apps communicating directly with WHOOP straps. Start with the device profile, then follow the operation or record you need. -Documentation coverage is broader than NOOP’s implemented command and decoder subset. - - - - - - - - +Documentation coverage is broader than any one implementation's command and record subset. ## Scope and compatibility @@ -19,72 +11,57 @@ frame headers and connection flows. A shared command name does not establish the same request or response bytes. WHOOP 5 historical data is not a WHOOP 4 layout with shifted offsets. -The WHOOP 5/MG topic references use **firmware 50.42.1.0** as their common baseline +The WHOOP 5/MG topic references use **firmware 50.42.1.0** as their common version baseline unless a passage explicitly identifies an earlier observation or a client-only -interpretation. The compared WHOOP 5 and MG firmware images were byte-identical; -this supports one shared profile, while ECG still depends on hardware capability. -It does not establish identical images across every release or identical features -on both devices. These baseline contracts do not claim device validation. +interpretation. WHOOP 5 and MG use one shared protocol profile at this version, +while ECG still depends on hardware capability. This does not establish identical +features across every release or both devices. These baseline contracts do not +claim device validation. -Earlier NOOP observations cover ECG on MG **50.39.1.0**, optical/IMU decoding and +Earlier observations cover ECG on MG **50.39.1.0**, optical/IMU records and reboot on **50.40.1.0**, R-R conversion on **50.41.1.0**, and older Hello decoding -on **50.38.1.0**. The WHOOP 4 enumeration report used **41.16.6.0**. Client-only IMU -stream descriptions refer to Android app **5.465.0**. Their distinct limits remain -at the relevant operation; they do not override the baseline. WHOOP 4 is a legacy -implementation/capture profile, with no equivalent complete command coverage here. +on **50.38.1.0**. The WHOOP 4 profile is centered on **41.17.6.0** where +that version is retained; feature-name enumeration is a separately bounded +**41.16.6.0** capture, and 41.17.6.0 can emit version-25 records depending on +device configuration. Their distinct limits remain at the relevant operation; +they do not override the baseline. WHOOP 4 is a version-bounded wire profile. +It is documented to the same topical +boundaries as WHOOP 5/MG, but it does not inherit the 50.42.1.0 command set, +configuration namespaces, sensor layouts or update interfaces where no WHOOP 4 +contract is documented. + +Each contract states its applicable family and version. Observed device behavior, +application behavior and version-specific support are kept distinct; +successful acknowledgement is never treated as proof of persistence or physical +effect. | Profile | Start here | Interpretation | |---|---|---| | WHOOP 4 | [WHOOP 4 profile](PROTOCOL_WHOOP4.md) | Legacy connection, framing, identity and record conventions | -| WHOOP 5 / MG | [WHOOP 5 / MG profile](PROTOCOL_WHOOP5.md) | Shared transport; check capabilities independently | +| WHOOP 5/MG | [WHOOP 5/MG profile](PROTOCOL_WHOOP5.md) | Shared transport; check capabilities independently | | All readers | [Shared concepts](PROTOCOL_CONCEPTS.md) | Integrity, request lifecycle and durable history handling | Command revision, record layout and inner record version remain explicit byte selectors throughout the reference. Unknown means unresolved, not unsupported. -“Defined” does not mean available in every state or fully implemented by NOOP. - - - - - - - - - - - - - - - - - - - - - - - - - - +“Defined” does not mean available in every state or implemented by every application. ## Reading guide | Task | Authoritative topic | |---|---| | Frame, correlate and recover a connection | [Transport](PROTOCOL_TRANSPORT.md) | -| Find an operation or its limitations | [Command reference](PROTOCOL_COMMANDS.md): 159 IDs, 71 defined and 88 unsupported in the baseline context | +| Compare an operation or its limitations | [Command matrix](PROTOCOL_COMMANDS.md#canonical-command-matrix): every ID 1–159 once; the documented WHOOP 4 firmware 41.17.6.0 command set has 85 `S` and 47 `U` entries across IDs 1–132, while WHOOP 5/MG has 71 supported and 88 unsupported IDs | | Configure collection and output | [Configuration](PROTOCOL_CONFIGURATION.md): 8 device keys and 25 feature descriptors | | Decode measurements | [Sensor records](PROTOCOL_SENSORS.md) | | Control and decode MG ECG | [ECG](PROTOCOL_ECG.md) | | Schedule or stop an alarm | [Alarms](PROTOCOL_ALARMS.md) | | Understand image transfer and authorization | [Updates and authorization](PROTOCOL_UPDATES.md) | -| Work on NOOP’s integration | [Implementation and historical observations](PROTOCOL_IMPLEMENTATION.md) | +| Know the hardware behind a contract | Hardware overview on the [WHOOP 4](PROTOCOL_WHOOP4.md#hardware-overview) and [WHOOP 5/MG](PROTOCOL_WHOOP5.md#hardware-overview) profile pages | +| Work on the NOOP integration | [Contract-to-implementation map](PROTOCOL_IMPLEMENTATION.md#protocol-contract-to-implementation-map) | | Reproduce selected parsing rules | [Constructed examples](protocol-examples/validate_examples.py) | -Each contract has one authoritative topic. Historical experiments, client timers -and decoder conventions are labeled separately. In particular, the older +Each contract has one authoritative topic. Historical experiments and +application-specific timing or interpretation conventions are labeled separately. In particular, the older [deep-data experiment](WHOOP5_DEEP_DATA.md) is not a universal enable recipe. ## Remaining boundaries @@ -94,13 +71,49 @@ meanings, complete bootloader acceptance and several runtime/error interactions remain unresolved. Local limits are recorded beside each contract. Constructed examples check selected arithmetic and state rules; they are not device tests. - - ## Project and credits NOOP is an independent, offline companion and is not affiliated with WHOOP or a medical device. See [disclaimer](../DISCLAIMER.md) and [attribution](../ATTRIBUTION.md). The existing work builds on `johnmiddleton12/my-whoop` (WHOOP 4) and `b-nnett/goose` (WHOOP 5); further credits remain with the historical observations. -The Swift protocol package and Android implementation are indexed in the +The Swift protocol package and Android protocol entry points are indexed in the [file map](PROTOCOL_IMPLEMENTATION.md#11-file-map). + +## Legacy anchors + +The following anchors keep older links into this page resolvable. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/PROTOCOL_ALARMS.md b/docs/PROTOCOL_ALARMS.md index b6d91364c4..e26ee8dc6a 100644 --- a/docs/PROTOCOL_ALARMS.md +++ b/docs/PROTOCOL_ALARMS.md @@ -2,8 +2,75 @@ Applicability: [central scope and compatibility](PROTOCOL.md#scope-and-compatibility). -This companion to the [command reference](PROTOCOL_COMMANDS.md#haptics-and-alarms) applies to the reference baseline. Earlier device observations remain separately scoped; the contracts below do not establish a successful physical wake on this version. - +This companion to the [command reference](PROTOCOL_COMMANDS.md#haptics-and-alarms) +separates the WHOOP 4 contract below from the later reference baseline. Neither +generation's command acknowledgement alone establishes a successful physical wake. + + + +## WHOOP 4 + +WHOOP 4 uses the same numeric alarm-family IDs as later devices but different +revision bodies. The statements below describe version-bounded device observations +and supported interoperability behavior; they must not be mixed with the revision-4/revision-2 baseline +later in this chapter. + +| Command | WHOOP 4 request body | Validation and boundary | +|---:|---|---| +| 66 SET_ALARM_TIME | `01 \|\| epoch_seconds:u32le \|\| subseconds:u16le` (7 semantic bytes) | **Documented for this version:** working requests observed in device captures appended two zero bytes that are not evaluated. A seven-byte request was acknowledged but did not vibrate; that observation concerns the subsecond field and does not make the semantic body nine bytes. | +| 67 GET_ALARM_TIME | `01` | **Field captures + supported behavior:** used as readback; response layouts vary/are incompletely mapped, so raw payload is retained and no behavior depends on it | +| 68 RUN_ALARM | `01` | **Observed supported behavior:** paired with preset haptics for an immediate user buzz; completion semantics remain incomplete | +| 69 DISABLE_ALARM | `01` | **Supported behavior:** disarms the legacy slot when the command reaches a connected strap; a failed request leaves the strap possibly armed | +| 79 RUN_HAPTICS_PATTERN | `02 03 00 00 00` for the proven preset | **Observed supported behavior:** preset 2, three loops, used for the graduated alarm buzz | +| 80 GET_ALL_HAPTICS_PATTERN | legacy read operation | **Known operation:** complete response vocabulary and physical mapping unresolved | +| 122 STOP_HAPTICS | `00` in the legacy request | **P / U · implemented by NOOP outside the documented 41.17.6.0 command set:** stops an in-progress haptic request; asynchronous completion and every firmware state are not mapped | + +### Scheduled alarm lifecycle on WHOOP 4 + +1. Confirm that command responses can arrive; a + Bluetooth connection alone is not proof that alarm responses can arrive. +2. `SET_CLOCK` and `GET_CLOCK` are outside the documented 41.17.6.0 command set. + On some devices one of the two SET_CLOCK forms was observed to latch; read back + to confirm. A correct clock remains a separate prerequisite. +3. **Observed request:** send command 66 with the seven-byte semantic body; the + working request form appends two zero bytes that are not evaluated. The epoch + is the absolute UTC instant corresponding to the user's chosen local wake time; + subseconds and the appended bytes are zero in the observed form. +4. Request command-67 readback and retain raw bytes, result and + device identity. An acknowledgement without matching readback does not prove + persistence. +5. **Device report + event capture convention:** event 57 identifies strap-driven + alarm execution. It is stronger than SET acknowledgement but still does not + measure motor force or prove that the wearer woke. + +The observed nine-byte request form is a working encoding of the seven-byte +semantic body plus two unevaluated zero bytes. It does **not** establish multiple alarm slots, +recurrence, atomic storage, reboot survival on every firmware, daylight-saving +logic, maximum schedule horizon or exact firing latency. A client implements +weekday recurrence by arming the next absolute instant. + +### Immediate WHOOP 4 haptics + +The observed reliable one-shot path sends command 79 with preset 2/three loops +and command 68 with revision byte 1, both as acknowledged writes. A bare preset +write was reported ignored in one run. The paired observation does not prove that +both commands are universally required by firmware. + +Event 60 (`HAPTICS_FIRED`) and event 100 (`HAPTICS_TERMINATED`) exist in the legacy +event vocabulary. Absence of either event is not proof that the motor did not move. +Pattern IDs, loop units, intensity, thermal/current limits, concurrency rules and +the complete command-80 enumeration remain open. + +On 41.17.6.0, commands 66–69, 73/74 and 79/80 are supported. SET parses seconds +and subseconds, rejects a past time and stores/enables a valid alarm; pattern +operations validate IDs and cap loop count. This does not establish the exact +request padding for every version, complete response layouts, motor waveform or +physical execution. Command 122 is implemented outside the documented 41.17.6.0 +command set, so its behavior remains separately versioned. + + + +## WHOOP 5/MG Alarm configuration supports six IDs, 1–6. SET uses revision 4 and the following 21-byte body; GET takes `[4, ID]` and, for a valid ID, returns the same 21-byte record. Multibyte fields are little-endian. @@ -19,9 +86,9 @@ Alarm configuration supports six IDs, 1–6. SET uses revision 4 and the followi | 19 | 1 | Duration | | 20 | 1 | Crescendo: 0 ordinary pattern, 1 staged crescendo control | -The earlier NOOP 20-byte alarm model receives a zero byte at offset 20 from format-1 padding. With the same sequence and other fields, it produces the same padded body as explicitly supplying crescendo zero. The newly described field does not show that earlier transmitted requests were too short or explain an unsuccessful wake by itself. +The earlier 20-byte alarm record receives a zero byte at offset 20 from format-1 padding. With the same sequence and other fields, it produces the same padded body as explicitly supplying crescendo zero. The newly described field does not show that earlier transmitted requests were too short or explain an unsuccessful wake by itself. -SET requires seconds strictly later than the strap's current seconds: a larger fractional value within the same second is insufficient. Each effect byte must be at most 251, repeat count must be below 8, and crescendo must be 0 or 1. When repeats equal 7, duration must be 30–120 inclusive; this interval is not imposed by this validator for repeats 0–6. A parser-accepted effect or duration is not a guarantee of a useful physical waveform. The full effect vocabulary, physical intensity and every operating limit remain unresolved. +SET requires seconds strictly later than the strap's current seconds: a larger fractional value within the same second is insufficient. Each effect byte must be at most 251, repeat count must be below 8, and crescendo must be 0 or 1. When repeats equal 7, duration must be 30–120 inclusive; this interval is not imposed for repeats 0–6. An accepted effect or duration is not a guarantee of a useful physical waveform. The full effect vocabulary, physical intensity and every operating limit remain unresolved. SET's response body is `[4, validation detail]`. This detail is distinct from the outer command result: @@ -35,32 +102,52 @@ SET's response body is `[4, validation detail]`. This detail is distinct from th | 11 | Alarm ID out of range | | 12 | Crescendo out of range | -Validation failures return outer failure. Detail 1 can accompany outer success **or failure**, because saving the record can still fail. The alarm-set event 56 is not an independent proof that storage succeeded. - -SET saves the pattern portion before the time portion. A pattern-write failure prevents the time write; a time-write failure can leave the new pattern with the previous schedule. Alarm records use nonvolatile storage, but writes are not established as atomic and survival of a particular power interruption has not been validated. GET reloads storage, yet failed reads can substitute zeros while GET still reports success. An all-zero time can therefore mean cleared/unset state or a storage-read fallback. Do not treat it as a separate storage-health result. Invalid-ID/revision failure bodies are not usable alarm records. +Validation failures return outer failure. Detail 1 can accompany outer success +**or failure**. A pattern can be stored while the time is rejected, so read back +both parts when their final state matters. The alarm-set event 56 is not independent +proof of persistence. Alarm updates are not established as atomic or power-loss +safe. GET can return zeros after a read failure while still reporting success; +an all-zero time can therefore mean cleared/unset state or a read fallback. +Invalid-ID/revision failure bodies are not usable alarm records. ### Disable, due processing and manual run -DISABLE uses `[2, ID]` for one alarm or `[2,255]` for all six. Its outer result reflects storage success/failure, and its body carries revision 2. All-ID disable attempts every slot even after an individual write fails, retaining a failure result if any write fails. Failure can therefore follow partial clearing; read back individual slots when their state matters. Disabling clears the saved record; it does not substitute for stopping an already active haptic effect. - -Due processing compares the alarm's whole seconds with the strap clock. Fractional ticks are retained but do not make this due check subsecond-precise. The scan cadence is nominally about half a second; exact intervals and worst-case latency remain unresolved. A due alarm is copied into active state and its stored record is cleared before haptic completion: it is a **one-shot schedule**, not a daily recurrence. Repeat count controls waveform repetition. Storage-clear failure remains separate. Simultaneous due slots share one execution context: the highest due ID in the ascending scan replaces the shared fields; independent simultaneous playback is not guaranteed. - -RUN uses `[2, ID]` and requires an existing nonzero stored time. It does not apply SET's future-time check. A valid initial response is outer pending with body `[2,0]`; an invalid/unset ID fails with `[2,11]`. The later response body is `[2, detail]`, with outer success only when detail equals 5. Timeout returns failure with `[2,7]`. The timeout's wall-clock duration is not specified here. RUN's active execution path also clears the selected saved record, so it is not a guaranteed nondestructive preview of a future schedule. - -Strap-triggered execution emits event 57; app-triggered RUN emits event 58. Ordinary pattern and crescendo paths are distinct. These events identify execution requests; they do not independently prove motor movement or that a person woke up. Haptic callbacks, driver errors, concurrent UI work and full crescendo timing still impose unresolved boundaries. Earlier alarm arming acknowledgements remain useful but do not establish an observed strap-driven wake. - - -## Busy execution and stop completion - -Alarm schedules share one active execution context. When multiple alarms become -due in the same scan, the last slot scanned replaces the shared alarm fields; -separate execution of every due alarm is not guaranteed. Avoid overlapping -schedules and overlapping manual haptic requests. - -An active alarm can still allow schedule scanning. A newly due alarm may be -consumed while its start request is ignored by the busy state. Its fields may -also replace fields used by the active execution. A successful schedule write -therefore does not guarantee later vibration. +DISABLE uses `[2, ID]` for one alarm or `[2,255]` for all six. Its body carries +revision 2. All-ID disable can clear some slots and still return failure when at +least one slot was not cleared; read back individual slots when their state matters. +Disabling clears the saved record; it does not substitute for stopping an already +active haptic effect. + +The strap compares an alarm's whole seconds with its clock at a nominal cadence of +about half a second. Fractional ticks are retained but do not make this comparison +subsecond-precise; exact intervals and worst-case latency remain unresolved. A due +alarm is cleared before haptic completion, so it is a **one-shot schedule**, not a +daily recurrence. Clearing can fail separately. When several slots become due +together, the highest ID can be the one executed; independent playback of every +due alarm is not guaranteed. + +RUN uses `[2, ID]` and requires an existing nonzero stored time. It does not apply +SET's future-time check. A valid initial response is outer pending with body `[2,0]`; +an invalid or unset ID fails with `[2,11]`. The later response body is `[2, detail]`, +with outer success only when detail equals 5. Timeout returns failure with `[2,7]`; +its duration is not specified here. RUN also clears the selected saved record, so +it is not a guaranteed nondestructive preview of a future schedule. + +Strap-triggered execution emits event 57; client-triggered RUN emits event 58. +Ordinary patterns and crescendo can produce different results. These events identify +execution attempts; they do not independently prove motor movement or that a person +woke up. Concurrent activity and full crescendo timing remain unresolved. Earlier +alarm arming acknowledgements do not establish an observed strap-driven wake. + +### Busy execution and stop completion + +When multiple alarms become due together, later slots can supersede earlier ones; +separate execution of every due alarm is not guaranteed. Avoid overlapping schedules +and overlapping manual haptic requests. + +While a haptic is active, a newly due alarm can be cleared without starting a +second vibration, and it can alter the reported active alarm. A successful schedule +write therefore does not guarantee later vibration. Stopping haptics is asynchronous. Distinguish the initial pending response from the final result. Stop completion and start completion have different success @@ -71,32 +158,26 @@ The notification-haptic body contains a revision, eight effect bytes, a little-endian loop-control field and an overall repeat byte. The start response contains revision and detail. Preserve the operation-specific response layout. -Crescendo uses staged control. Physical intensity, reliable stage timing and -motor output are not established by an accepted request. Short durations are -not established safe crescendo settings. In a later stage, the remaining duration -is calculated as unsigned 32-bit `duration - 20`, then multiplied by ten for a -timer count with the same wrapping arithmetic; a zero count becomes one. Durations -below 20 are not rejected when repeats are below 7, so acceptance does not prevent -this underflow. An independently armed total-duration stop can intervene first; -the arithmetic is not evidence of an extremely long physical buzz. - -A start timeout can submit up to three retries before returning timeout detail 7. -The app must not interpret each retry as a separate alarm or immediately issue -another start while awaiting the correlated completion. Complete driver callback -behavior and the first crescendo stage's duration remain unresolved. - -Schedules are read from storage during scanning. After restart, execution still -depends on readable retained schedules, a correct clock and the application -reaching the scanning state. Reboot survival and wake latency require separate -validation. No automatic ECG or sensor-request cleanup on disconnect is promised. +Crescendo uses staged output. Physical intensity, reliable stage timing and motor +output are not established by an accepted request. Durations below 20 are accepted +when repeats are below 7, but later timing can be irregular; acceptance is therefore +not evidence that a short crescendo setting is safe or useful. A separate total-duration +stop can end the effect first. + +Before returning timeout detail 7, the strap can make up to three start attempts. +A client must not interpret each attempt as a separate alarm or issue another +start while awaiting the correlated completion. The first crescendo stage's +duration remains unresolved. + +After restart, execution still depends on readable retained schedules and a correct +clock. Reboot survival and wake latency require separate validation. No automatic +ECG or sensor-request cleanup on disconnect is promised. Command 122 takes revision 1 alone. Initial pending and final success/failure all carry the one-byte body `[1]`; unsupported revision also fails with `[1]`. The final result is operation-specific and must not be inferred from the start response detail. Command 19 takes 12 bytes and returns revision plus detail; its final success uses detail 5, as does manual RUN. -NOOP’s single-notification command-19 body is `01 2F 98 00 00 00 00 00 00 00 00 00`: +The observed single-notification command-19 body is `01 2F 98 00 00 00 00 00 00 00 00 00`: revision 1, effects `[47,152,0,0,0,0,0,0]`, zero effect-loop control and zero repeats. - -The nominal half-second scan scale comes from five received timer ticks followed -by event dispatch. Interrupt handling restarts the timer, so this is not an exact -free-running scan period, motor-start deadline or wake guarantee. Oscillator error, -interrupt handling and scheduling can affect the actual interval. +The alarm scan runs at a nominal half-second cadence. This is not an exact +free-running scan period, motor-start deadline or wake guarantee: the actual +interval can vary with oscillator error and scheduling on the strap. diff --git a/docs/PROTOCOL_COMMANDS.md b/docs/PROTOCOL_COMMANDS.md index e202c1fbcf..6d59898b3f 100644 --- a/docs/PROTOCOL_COMMANDS.md +++ b/docs/PROTOCOL_COMMANDS.md @@ -1,240 +1,354 @@ -# WHOOP complete command reference +# WHOOP generation-comparative command reference + + Applicability: [central scope and compatibility](PROTOCOL.md#scope-and-compatibility). -This reference extends [the existing protocol documentation](PROTOCOL.md); it is not a list of commands that NOOP automatically sends. The catalog covers **all IDs 1–159 in the documented command context**: 71 have a defined operation and 88 use the unsupported response. “Defined” does not mean fully decoded, available on every WHOOP 5/MG hardware variant, permitted in every state, or successfully exercised on a device. There are no device-validation claims for the reference baseline here; earlier observations are labeled with their own scope. - -Names are identifiers, not sufficient evidence of behavior. Historical names are retained for recognition even where the current version does not support the operation. An unnamed unsupported ID has no assigned semantics. Do not extrapolate this catalog to other firmware versions, command contexts or IDs outside the range. - -Requests below describe semantic command bodies, excluding outer padding. Unless stated otherwise, exact request/response bytes, initial state, prerequisite, persistence and reversal remain unknown. Common results and request-origin correlation are in [transport behavior](PROTOCOL_TRANSPORT.md#responses-and-correlation). An accepted request is not proof that its eventual effect occurred. - -## All command IDs - -Each numeric ID appears once in this catalog. **D** means defined, with the stated limits and linked contract; **U** means unsupported in the documented command context: result 3, empty semantic body. A known name on a U row is a historical identifier, not a current supported effect. - -| ID | Name / identifier | Status | Meaning and contract | -|---:|---|:---:|---| -| 1 | `LINK_VALID` | D | Fixed acknowledgement; not identity. [Details](#core-command-contracts) | -| 2 | `GET_MAX_PROTOCOL_VERSION` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 3 | `TOGGLE_REALTIME_HR` | D | Live HR toggle; older NOOP body `0`/`1`, current acceptance unresolved. [Details](#core-command-contracts) | -| 4 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 5 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 6 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 7 | `REPORT_VERSION_INFO` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 8 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 9 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 10 | `SET_CLOCK_DEPRECATED` | D | Deprecated clock setter; do not use the high-opcode body. [Details](#core-command-contracts) | -| 11 | `GET_CLOCK_DEPRECATED` | D | Deprecated clock reader; current reply layout unresolved. [Details](#core-command-contracts) | -| 12 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 13 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 14 | `TOGGLE_GENERIC_HR_PROFILE` | D | Boolean generic-HR policy; nonvolatile setting, downstream GATT effect unresolved. [Details](#core-command-contracts) | -| 15 | `Forget bonds` | D | Remove pairing bonds; destructive lifecycle change. [Details](#service-and-sensitive-operations) | -| 16 | `TOGGLE_R7_DATA_COLLECTION` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 17 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 18 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 19 | `RUN_HAPTIC_PATTERN_MAVERICK` | D | Notification haptics; revision-1 pattern. [Details](#haptics-and-alarms) | -| 20 | `ABORT_HISTORICAL_TRANSMITS` | D | Stop historical transmission, not trim. [Details](#core-command-contracts) | -| 21 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 22 | `SEND_HISTORICAL_DATA` | D | Request historical transmission; delivery is asynchronous. [Details](#core-command-contracts) | -| 23 | `HISTORICAL_DATA_RESULT` | D | Acknowledge a committed history chunk; permits reclamation. [Details](#core-command-contracts) | -| 24 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 25 | `FORCE_TRIM` | D | Force history trimming; invasive cursor mutation. [Details](#service-and-sensitive-operations) | -| 26 | `GET_BATTERY_LEVEL` | D | Asynchronous battery query; four-byte u32 whole-percent final body. [Details](PROTOCOL_TRANSPORT.md#battery-level--command-26) | -| 27 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 28 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 29 | `REBOOT_STRAP` | D | Reboot and interrupt current work. [Details](#service-and-sensitive-operations) | -| 30 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 31 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 32 | `POWER_CYCLE_STRAP` | D | Power-cycle; current runtime and preservation guarantees unresolved. [Details](#service-and-sensitive-operations) | -| 33 | `SET_READ_POINTER` | D | Change history read position; invasive cursor mutation. [Details](#service-and-sensitive-operations) | -| 34 | `GET_DATA_RANGE` | D | Pending then 65-byte range reply; cursor roles and remaining clock limits specified. [Details](PROTOCOL_TRANSPORT.md#data-range--command-34) | -| 35 | `GET_HELLO_HARVARD` | D | Legacy Hello branch does not build a local command reply. [Details](PROTOCOL_TRANSPORT.md#hello--command-145) | -| 36 | `START_FIRMWARE_LOAD` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 37 | `LOAD_FIRMWARE_DATA` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 38 | `PROCESS_FIRMWARE_IMAGE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 39 | `SET_LED_DRIVE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 40 | `GET_LED_DRIVE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 41 | `SET_TIA_GAIN` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 42 | `GET_TIA_GAIN` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 43 | `SET_BIAS_OFFSET` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 44 | `GET_BIAS_OFFSET` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 45 | `ENTER_BLE_DFU` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 46 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 47 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 48 | `SEND_EVENT_PACKETS` | D | Toggle event delivery, not a proven flush of stored events. [Details](#core-command-contracts) | -| 49 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 50 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 51 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 52 | `SET_DP_TYPE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 53 | `FORCE_DP_TYPE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 54 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 55 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 56 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 57 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 58 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 59 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 60 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 61 | `SET_AFE_PARAMETERS` | D | Set AFE channel/setting/value; operating AFE required. [Details](PROTOCOL_CONFIGURATION.md) | -| 62 | `GET_AFE_PARAMETERS` | D | Read cached AFE channel/setting/value; three-word body, no revision prefix. [Details](PROTOCOL_CONFIGURATION.md) | -| 63 | `SEND_R10_R11_REALTIME` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 64 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 65 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 66 | `SET_ALARM_TIME` | D | Set revision-4 time, pattern and crescendo for ID 1–6. [Details](#haptics-and-alarms) | -| 67 | `GET_ALARM_TIME` | D | Read revision-4 21-byte alarm record; storage fallback caveat. [Details](#haptics-and-alarms) | -| 68 | `RUN_ALARM` | D | Run a stored alarm; pending/final response, consumes saved schedule. [Details](#haptics-and-alarms) | -| 69 | `DISABLE_ALARM` | D | Clear one saved alarm or all six; distinct from stopping active haptics. [Details](#haptics-and-alarms) | -| 70 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 71 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 72 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 73 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 74 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 75 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 76 | `GET_ADVERTISING_NAME_HARVARD` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 77 | `SET_ADVERTISING_NAME_HARVARD` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 78 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 79 | `RUN_HAPTICS_PATTERN` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 80 | `GET_ALL_HAPTICS_PATTERN` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 81 | `START_RAW_DATA` | D | Start raw production; separate from saving and streaming. [Details](PROTOCOL_CONFIGURATION.md) | -| 82 | `STOP_RAW_DATA` | D | Stop raw production; not a substitute for every collection policy. [Details](PROTOCOL_CONFIGURATION.md) | -| 83 | `VERIFY_FIRMWARE_IMAGE` | D | Incremental integrity check with correlated asynchronous final result; boot acceptance separate. [Details](#service-and-sensitive-operations) | -| 84 | `GET_BODY_LOCATION_AND_STATUS` | D | Revision 1; fixed four-byte cached status, with location/confidence placeholders. [Details](#ordinary-service-commands) | -| 85 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 86 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 87 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 88 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 89 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 90 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 91 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 92 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 93 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 94 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 95 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 96 | `ENTER_HIGH_FREQ_SYNC` | D | Request high-frequency sync schedule; period/duration constraints below. [Details](#core-command-contracts) | -| 97 | `EXIT_HIGH_FREQ_SYNC` | D | Request leaving high-frequency sync; acceptance precedes asynchronous effect. [Details](#core-command-contracts) | -| 98 | `GET_EXTENDED_BATTERY_INFO` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 99 | `RESET_FUEL_GAUGE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 100 | `CALIBRATE_CAPSENSE` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 101 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 102 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 103 | `Disable BLE UART` | D | Change BLE UART service state; safe readback unresolved. [Details](#service-and-sensitive-operations) | -| 104 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 105 | `TOGGLE_IMU_MODE_HISTORICAL` | D | IMU session saving contribution in RAM. [Details](PROTOCOL_CONFIGURATION.md) | -| 106 | `TOGGLE_IMU_MODE` | D | Live IMU transport toggle. [Details](PROTOCOL_CONFIGURATION.md) | -| 107 | `ENABLE_OPTICAL_DATA` | D | Optical session saving contribution in RAM. [Details](PROTOCOL_CONFIGURATION.md) | -| 108 | `TOGGLE_OPTICAL_MODE` | D | Live optical transport toggle. [Details](PROTOCOL_CONFIGURATION.md) | -| 109 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 110 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 111 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 112 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 113 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 114 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 115 | `START_DEVICE_CONFIG_KEY_EXCHANGE` | D | Reset device-key enumeration and return count. [Details](PROTOCOL_CONFIGURATION.md) | -| 116 | `SEND_NEXT_DEVICE_CONFIG` | D | Advance device-key cursor; names, not values. [Details](PROTOCOL_CONFIGURATION.md) | -| 117 | `START_FF_KEY_EXCHANGE` | D | Reset feature-key enumeration and return count. [Details](PROTOCOL_CONFIGURATION.md) | -| 118 | `SEND_NEXT_FF` | D | Advance feature-key cursor; names, not values. [Details](PROTOCOL_CONFIGURATION.md) | -| 119 | `SET_DEVICE_CONFIG_VALUE` | D | Typed named device-configuration SET. [Details](PROTOCOL_CONFIGURATION.md) | -| 120 | `SET_FF_VALUE` | D | Typed named feature-flag SET. [Details](PROTOCOL_CONFIGURATION.md) | -| 121 | `GET_DEVICE_CONFIG_VALUE` | D | Read named device configuration from storage. [Details](PROTOCOL_CONFIGURATION.md) | -| 122 | `STOP_HAPTICS` | D | Revision 1 only; asynchronous pending/final stop, one-byte body. [Details](PROTOCOL_ALARMS.md#busy-execution-and-stop-completion) | -| 123 | `SELECT_WRIST` | D | ECG wrist: revision 1, right 1 / left 2; persistence unproved. [Details](PROTOCOL_ECG.md) | -| 124 | `TOGGLE_LABRADOR_DATA_GENERATION` | D | ECG processing: revision 1, stop 1 / start 2 or 3; hardware guarded. [Details](PROTOCOL_ECG.md) | -| 125 | `TOGGLE_LABRADOR_RAW_SAVE` | D | Boolean raw-ECG saving, independent of live transport. [Details](PROTOCOL_ECG.md) | -| 126 | `Send raw ECG` | D | Boolean raw-ECG live transport. [Details](PROTOCOL_ECG.md) | -| 127 | `Save filtered ECG` | D | Boolean filtered-ECG saving. [Details](PROTOCOL_ECG.md) | -| 128 | `GET_FF_VALUE` | D | Read named feature configuration from storage. [Details](PROTOCOL_CONFIGURATION.md) | -| 129 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 130 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 131 | `SET_RESEARCH_PACKET` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 132 | `GET_RESEARCH_PACKET` | U | Historical identifier only; current arguments and effect not supported. [Details](#unsupported-and-cross-version-commands) | -| 133 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 134 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 135 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 136 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 137 | `Unknown` | U | No assigned meaning; no supported request body. [Details](#unsupported-and-cross-version-commands) | -| 138 | `Set signal-processing configuration` | D | Revision 1 plus selector; 0–8 presets, 9–255 acknowledge without selecting. [Details](#ordinary-service-commands) | -| 139 | `TOGGLE_LABRADOR_FILTERED` | D | Boolean filtered-ECG live transport. [Details](PROTOCOL_ECG.md) | -| 140 | `SET_ADVERTISING_NAME` | D | Revision 1; advertising name at most 15 bytes, update requested after storage. [Details](#ordinary-service-commands) | -| 141 | `GET_ADVERTISING_NAME` | D | Revision 1; fixed 19-byte name response. [Details](#ordinary-service-commands) | -| 142 | `START_FIRMWARE_LOAD_NEW` | D | Begin executable-image transfer; persistent mutation. [Details](#service-and-sensitive-operations) | -| 143 | `LOAD_FIRMWARE_DATA_NEW` | D | Write bounded executable-image chunk; persistent mutation. [Details](#service-and-sensitive-operations) | -| 144 | `PROCESS_FIRMWARE_IMAGE_NEW` | D | Process transferred image; later validation/activation incomplete. [Details](#service-and-sensitive-operations) | -| 145 | `GET_HELLO` | D | Revision 1/3 Hello; pending then asynchronous identity response. [Details](#core-command-contracts) | -| 146 | `SET_CLOCK` | D | Revision-1 seconds/ticks clock SET; hundredths precision. [Details](#core-command-contracts) | -| 147 | `GET_CLOCK` | D | Revision-1 seconds/ticks clock GET; zero-time caveat. [Details](#core-command-contracts) | -| 148 | `Wear-detection override` | D | Revision 1; 1 forces worn and disables detection, 0 restores detection. [Details](#ordinary-service-commands) | -| 149 | `Set LED accessibility` | D | Revision 1 boolean; persistent LED accessibility option. [Details](#ordinary-service-commands) | -| 150 | `Set gyro mode (Disable Gyro)` | D | Revision-1 gyro SET: 0 disabled, 1 enabled; partial failure possible. [Details](PROTOCOL_CONFIGURATION.md) | -| 151 | `GET_BATTERY_PACK_INFO` | D | Revision 1; cached 28-byte pack body, presence and freshness distinct. [Details](PROTOCOL_TRANSPORT.md#battery-pack--command-151) | -| 152 | `Get gyro mode status` | D | Revision-1 cached gyro-mode predicate; not fresh sensor read. [Details](PROTOCOL_CONFIGURATION.md) | -| 153 | `TOGGLE_PERSISTENT_R20` | D | Persistent optical/R20 collection contribution. [Details](PROTOCOL_CONFIGURATION.md) | -| 154 | `TOGGLE_PERSISTENT_R21` | D | Persistent IMU/R21 collection contribution. [Details](PROTOCOL_CONFIGURATION.md) | -| 155 | `START_CERTIFICATE_TRANSFER` | D | Begin certificate transfer; security-state mutation. [Details](#service-and-sensitive-operations) | -| 156 | `LOAD_CERTIFICATE` | D | Load certificate data; security-state mutation. [Details](#service-and-sensitive-operations) | -| 157 | `VERIFY_CERTIFICATE` | D | Verify transferred certificate, identity and freshness; detailed validation limits documented. [Details](#service-and-sensitive-operations) | -| 158 | `PROCESS_CERTIFICATE` | D | Process/store certificate material; security-state mutation. [Details](#service-and-sensitive-operations) | -| 159 | `LOCK_DEVICE` | D | Revision-1 queued authorization lock; conditional certificate clearing and reauthorization. [Details](#service-and-sensitive-operations) | +This is the canonical numeric command index for WHOOP 4 and WHOOP 5/MG. It is +not a transmission recommendation or a capability-test allowlist. +Equal numeric IDs are compared here; payload, response, lifecycle and side +effects remain governed by the linked generation contracts. + +## Contents + +- [Compatibility status](#compatibility-status) +- [Canonical command matrix](#canonical-command-matrix) +- [Unsupported and cross-version commands](#unsupported-and-cross-version-commands) +- [WHOOP 4](#whoop-4) + - [Version boundaries and negative space](#version-boundaries-and-negative-space) +- [WHOOP 5/MG](#whoop-5mg) + - [High-frequency sync scheduler](#high-frequency-sync-scheduler) + - [Haptics and alarms](#haptics-and-alarms) + - [Service and sensitive operations](#service-and-sensitive-operations) + - [Ordinary service commands](#ordinary-service-commands) + - [Image-transfer and certificate commands](#image-transfer-and-certificate-commands) + +## Compatibility status + +The WHOOP 4 column is version-bounded to **41.17.6.0** for IDs 1–132. Older captures and +interoperability behavior are retained only where they define a useful wire +contract; they do not override a conflicting 41.17.6.0 status. + +The WHOOP 5/MG column is version-bounded to **50.42.1.0** and covers every ID +1–159. A supported status means the command is recognized for that version; it +does not by itself guarantee that every request revision, device state or physical +effect has been validated. + +| Status | Meaning | +|---|---| +| **S** | Supported for the named firmware version; exact request, response or effect may still be incomplete. | +| **O** | Observed outside the documented command set for the named firmware version. | +| **P** | Partial contract or incomplete device validation. | +| **U** | Not part of the documented command set for the named firmware version. Silence or missing observations do not qualify a command as supported. | +| **?** | Unknown or not investigated for this generation. | + +Combined cells such as `O / U` or `P / U` mean that behavior was observed or +implemented outside the documented command set for the named firmware version. +Neither half overrides the other. + +The matrix contains every ID 1–159 exactly once. Its two operation columns are +generation-specific: a repeated name does not mean the wire contracts are equal. +WHOOP 4 has 85 `S`, 47 `U` and 27 IDs outside its version-bounded range; WHOOP +5/MG has 71 `S` and 88 `U`. Thirty-four numeric IDs are supported by both +versions, 51 only by WHOOP 4, 15 only by WHOOP 5/MG within the WHOOP 4 range, +and 22 only by WHOOP 5/MG outside it (IDs 138–159). Numeric overlap is not semantic parity. Names are generation-oriented +identifiers; they do not establish payload equality. Requests exclude outer +padding. Common result and correlation rules are in +[transport](PROTOCOL_TRANSPORT.md#responses-and-correlation). + + + +## Canonical command matrix + +| ID | WHOOP 4 operation / identifier | W4 status | WHOOP 5/MG operation / identifier | W5 status | Detail contracts | +|---:|---|:---:|---|:---:|---| +| 1 | — | U | `LINK_VALID` | S | [W5](#whoop-5mg) | +| 2 | — | U | — | U | — | +| 3 | `TOGGLE_REALTIME_HR` | P / U | `TOGGLE_REALTIME_HR` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 4 | — | U | — | U | — | +| 5 | — | U | — | U | — | +| 6 | `GET_HARDWARE_INFO` | S | — | U | — | +| 7 | `REPORT_VERSION_INFO` | S | — | U | [W4](#whoop-4) | +| 8 | — | U | — | U | — | +| 9 | — | U | — | U | — | +| 10 | `SET_CLOCK` | O / U | `SET_CLOCK_DEPRECATED` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 11 | `GET_CLOCK` | O / U | `GET_CLOCK_DEPRECATED` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 12 | — | U | — | U | — | +| 13 | — | U | — | U | — | +| 14 | `TOGGLE_GENERIC_HR_PROFILE` | S | `TOGGLE_GENERIC_HR_PROFILE` | S | [W5](#whoop-5mg) | +| 15 | `FORGET_BONDS` | S | `FORGET_BONDS` | S | [W5](#service-and-sensitive-operations) | +| 16 | `TOGGLE_R7_DATA_COLLECTION` | S | — | U | — | +| 17 | — | U | — | U | — | +| 18 | — | U | — | U | — | +| 19 | `SET_R7_REALTIME_STREAM` | S | `RUN_HAPTIC_PATTERN_MAVERICK` | S | [W4](#whoop-4) · [W5](#haptics-and-alarms) | +| 20 | `ABORT_HISTORICAL_TRANSMITS` | O / U | `ABORT_HISTORICAL_TRANSMITS` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 21 | — | U | — | U | — | +| 22 | `SEND_HISTORICAL_DATA` | O / U | `SEND_HISTORICAL_DATA` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 23 | `HISTORICAL_DATA_RESULT` | O / U | `HISTORICAL_DATA_RESULT` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 24 | — | U | — | U | — | +| 25 | `FORCE_TRIM` | S | `FORCE_TRIM` | S | [W5](#service-and-sensitive-operations) | +| 26 | `GET_BATTERY_LEVEL` | S | `GET_BATTERY_LEVEL` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 27 | — | U | — | U | — | +| 28 | — | U | — | U | — | +| 29 | `REBOOT_STRAP` | S | `REBOOT_STRAP` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 30 | — | U | — | U | — | +| 31 | — | U | — | U | — | +| 32 | `POWER_CYCLE_STRAP` | S | `POWER_CYCLE_STRAP` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 33 | `SET_READ_POINTER` | S | `SET_READ_POINTER` | S | [W5](#service-and-sensitive-operations) | +| 34 | `GET_DATA_RANGE` | S | `GET_DATA_RANGE` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 35 | `GET_HELLO_HARVARD` | S | `GET_HELLO_HARVARD` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 36 | `START_FIRMWARE_LOAD` | S | — | U | — | +| 37 | `LOAD_FIRMWARE_DATA` | S | — | U | — | +| 38 | `PROCESS_FIRMWARE_IMAGE` | S | — | U | — | +| 39 | `SET_LED_DRIVE` | S | — | U | — | +| 40 | `GET_LED_DRIVE` | S | — | U | — | +| 41 | `SET_TIA_GAIN` | S | — | U | — | +| 42 | `GET_TIA_GAIN` | S | — | U | — | +| 43 | `SET_BIAS_OFFSET` | S | — | U | — | +| 44 | `GET_BIAS_OFFSET` | S | — | U | — | +| 45 | `ENTER_BLE_DFU` | S | — | U | — | +| 46 | `SEND_R7_PACKETS` | S | — | U | — | +| 47 | `SEND_R9_PACKETS` | S | — | U | — | +| 48 | `SEND_EVENT_PACKETS` | S | `SEND_EVENT_PACKETS` | S | [W5](#whoop-5mg) | +| 49 | `SAVE_R7_PACKETS` | S | — | U | — | +| 50 | `SAVE_R9_PACKETS` | S | — | U | — | +| 51 | `RESET_SIGNAL_PROCESSING` | S | — | U | — | +| 52 | `SET_DP_TYPE` | S | — | U | — | +| 53 | `FORCE_DP_TYPE` | S | — | U | — | +| 54 | `GET_DP_TYPE` | S | — | U | — | +| 55 | `PERSISTENT_SAVE_R10_R11` | S | — | U | — | +| 56 | `PERSISTENT_SAVE_R9` | S | — | U | — | +| 57 | `PERSISTENT_SET_AFE_CHANNEL` | S | — | U | — | +| 58 | `CONFIGURE_RAW_TRANSMIT_ONLY` | S | — | U | — | +| 59 | `SEND_R10_R11_PACKETS` | S | — | U | — | +| 60 | `SAVE_R10_R11_PACKETS` | S | — | U | — | +| 61 | `SET_AFE_PARAMETERS` | S | `SET_AFE_PARAMETERS` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 62 | `GET_AFE_PARAMETERS` | S | `GET_AFE_PARAMETERS` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 63 | `SEND_R10_R11_REALTIME` | S | — | U | [W4](#whoop-4) | +| 64 | `PERSISTENT_SAVE_R10_R11_ALIAS` | S | — | U | — | +| 65 | `GET_PACKET_CONFIG` | S | — | U | — | +| 66 | `SET_ALARM_TIME` | S | `SET_ALARM_TIME` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 67 | `GET_ALARM_TIME` | S | `GET_ALARM_TIME` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 68 | `RUN_ALARM` | S | `RUN_ALARM` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 69 | `DISABLE_ALARM` | S | `DISABLE_ALARM` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 70 | `SAVE_R12_OR_R24_PACKETS` | S | — | U | — | +| 71 | `SEND_R12_OR_R24_PACKETS` | S | — | U | — | +| 72 | `PERSISTENT_SAVE_R12_OR_R24` | S | — | U | — | +| 73 | `SET_SMART_ALARM_HAPTICS_PATTERN` | S | — | U | — | +| 74 | `GET_SMART_ALARM_HAPTICS_PATTERN` | S | — | U | — | +| 75 | `GET_PROTOCOL_VERSION` | S | — | U | — | +| 76 | `GET_ADVERTISING_NAME_HARVARD` | S | — | U | [W4](#whoop-4) | +| 77 | `SET_ADVERTISING_NAME_HARVARD` | S | — | U | [W4](#whoop-4) | +| 78 | `OPERATION_UNRESOLVED` | S | — | U | — | +| 79 | `RUN_HAPTICS_PATTERN` | S | — | U | [W4](#whoop-4) | +| 80 | `GET_ALL_HAPTICS_PATTERN` | S | — | U | — | +| 81 | `START_RAW_DATA` | S | `START_RAW_DATA` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 82 | `STOP_RAW_DATA` | S | `STOP_RAW_DATA` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 83 | `VERIFY_FIRMWARE_IMAGE` | S | `VERIFY_FIRMWARE_IMAGE` | S | [W4](PROTOCOL_UPDATES.md#whoop-4) · [W5](PROTOCOL_UPDATES.md#image-transfer-command-boundaries) | +| 84 | `GET_BODY_LOCATION_AND_STATUS` | S | `GET_BODY_LOCATION_AND_STATUS` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 85 | `LOAD_FIRMWARE_DATA_ALIAS` | S | — | U | — | +| 86 | — | U | — | U | — | +| 87 | — | U | — | U | — | +| 88 | — | U | — | U | — | +| 89 | — | U | — | U | — | +| 90 | — | U | — | U | — | +| 91 | — | U | — | U | — | +| 92 | — | U | — | U | — | +| 93 | — | U | — | U | — | +| 94 | — | U | — | U | — | +| 95 | — | U | — | U | — | +| 96 | `ENTER_HIGH_FREQ_SYNC` | S | `ENTER_HIGH_FREQ_SYNC` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 97 | `EXIT_HIGH_FREQ_SYNC` | S | `EXIT_HIGH_FREQ_SYNC` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 98 | `GET_EXTENDED_BATTERY_INFO` | S | — | U | [W4](#whoop-4) | +| 99 | `RESET_FUEL_GAUGE` | S | — | U | — | +| 100 | `CALIBRATE_CAPSENSE` | S | — | U | — | +| 101 | `RESET_CAPSENSE` | S | — | U | — | +| 102 | `ENABLE_BLE_UART` | S | — | U | — | +| 103 | `DISABLE_BLE_UART` | S | `DISABLE_BLE_UART` | S | [W5](#whoop-5mg) | +| 104 | — | U | — | U | — | +| 105 | — | U | `TOGGLE_IMU_MODE_HISTORICAL` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 106 | `SET_IMU_DATA_STREAM` | S | `TOGGLE_IMU_MODE` | S | [W4](#whoop-4) · [W5](PROTOCOL_CONFIGURATION.md) | +| 107 | `GET_IMU_DATA_STREAM` | S | `ENABLE_OPTICAL_DATA` | S | [W4](#whoop-4) · [W5](PROTOCOL_CONFIGURATION.md) | +| 108 | — | U | `TOGGLE_OPTICAL_MODE` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 109 | — | U | — | U | — | +| 110 | — | U | — | U | — | +| 111 | — | U | — | U | — | +| 112 | — | U | — | U | — | +| 113 | — | U | — | U | — | +| 114 | — | U | — | U | — | +| 115 | `START_DEVICE_CONFIG_KEY_EXCHANGE` | S | `START_DEVICE_CONFIG_KEY_EXCHANGE` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 116 | `SEND_NEXT_DEVICE_CONFIG` | S | `SEND_NEXT_DEVICE_CONFIG` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 117 | `START_FF_KEY_EXCHANGE` | S | `START_FF_KEY_EXCHANGE` | S | [W4](#whoop-4) · [W5](PROTOCOL_CONFIGURATION.md) | +| 118 | `SEND_NEXT_FF` | S | `SEND_NEXT_FF` | S | [W4](#whoop-4) · [W5](PROTOCOL_CONFIGURATION.md) | +| 119 | `SET_DEVICE_CONFIG_VALUE` | S | `SET_DEVICE_CONFIG_VALUE` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 120 | `SET_FF_VALUE` | S | `SET_FF_VALUE` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 121 | `GET_DEVICE_CONFIG_VALUE` | S | `GET_DEVICE_CONFIG_VALUE` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 122 | `STOP_HAPTICS` | P / U | `STOP_HAPTICS` | S | [W4](#whoop-4) · [W5](#whoop-5mg) | +| 123 | — | U | `SELECT_WRIST` | S | [W5](PROTOCOL_ECG.md) | +| 124 | — | U | `TOGGLE_LABRADOR_DATA_GENERATION` | S | [W5](PROTOCOL_ECG.md) | +| 125 | — | U | `TOGGLE_LABRADOR_RAW_SAVE` | S | [W5](PROTOCOL_ECG.md) | +| 126 | — | U | `Send raw ECG` | S | [W5](PROTOCOL_ECG.md) | +| 127 | — | U | `Save filtered ECG` | S | [W5](PROTOCOL_ECG.md) | +| 128 | `GET_FF_VALUE` | S | `GET_FF_VALUE` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 129 | `SET_R12_REALTIME_STREAM` | S | — | U | — | +| 130 | `GET_R12_REALTIME_STREAM` | S | — | U | — | +| 131 | `SET_SEND_R19_PACKETS` | S | — | U | — | +| 132 | `GET_SEND_R19_PACKETS` | S | — | U | — | +| 133 | — | ? | — | U | — | +| 134 | — | ? | — | U | — | +| 135 | — | ? | — | U | — | +| 136 | — | ? | — | U | — | +| 137 | — | ? | — | U | — | +| 138 | — | ? | `SET_SIGNAL_PROCESSING_CONFIGURATION` | S | [W5](#ordinary-service-commands) | +| 139 | — | ? | `TOGGLE_LABRADOR_FILTERED` | S | [W5](PROTOCOL_ECG.md) | +| 140 | — | ? | `SET_ADVERTISING_NAME` | S | [W5](#ordinary-service-commands) | +| 141 | — | ? | `GET_ADVERTISING_NAME` | S | [W5](#ordinary-service-commands) | +| 142 | — | ? | `START_FIRMWARE_LOAD_NEW` | S | [W5](PROTOCOL_UPDATES.md#image-transfer-command-boundaries) | +| 143 | — | ? | `LOAD_FIRMWARE_DATA_NEW` | S | [W5](PROTOCOL_UPDATES.md#image-transfer-command-boundaries) | +| 144 | — | ? | `PROCESS_FIRMWARE_IMAGE_NEW` | S | [W5](PROTOCOL_UPDATES.md#image-transfer-command-boundaries) | +| 145 | — | ? | `GET_HELLO` | S | [W5](#whoop-5mg) | +| 146 | — | ? | `SET_CLOCK` | S | [W5](#whoop-5mg) | +| 147 | — | ? | `GET_CLOCK` | S | [W5](#whoop-5mg) | +| 148 | — | ? | `SET_WEAR_DETECTION_OVERRIDE` | S | [W5](#ordinary-service-commands) | +| 149 | — | ? | `SET_LED_ACCESSIBILITY` | S | [W5](#ordinary-service-commands) | +| 150 | — | ? | `Set gyro mode` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 151 | — | ? | `GET_BATTERY_PACK_INFO` | S | [W5](PROTOCOL_TRANSPORT.md#battery-pack--command-151) | +| 152 | — | ? | `Get gyro mode status` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 153 | — | ? | `TOGGLE_PERSISTENT_R20` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 154 | — | ? | `TOGGLE_PERSISTENT_R21` | S | [W5](PROTOCOL_CONFIGURATION.md) | +| 155 | — | ? | `START_CERTIFICATE_TRANSFER` | S | [W5](PROTOCOL_UPDATES.md#certificate-command-boundaries) | +| 156 | — | ? | `LOAD_CERTIFICATE` | S | [W5](PROTOCOL_UPDATES.md#certificate-command-boundaries) | +| 157 | — | ? | `VERIFY_CERTIFICATE` | S | [W5](PROTOCOL_UPDATES.md#certificate-command-boundaries) | +| 158 | — | ? | `PROCESS_CERTIFICATE` | S | [W5](PROTOCOL_UPDATES.md#certificate-command-boundaries) | +| 159 | — | ? | `LOCK_DEVICE` | S | [W5](PROTOCOL_UPDATES.md#certificate-command-boundaries) | ## Unsupported and cross-version commands -The U classification is version/context specific. For example, REPORT_VERSION_INFO, SEND_R10_R11_REALTIME and GET_EXTENDED_BATTERY_INFO have older family meanings or partial observations; that does not override their unsupported status here. The legacy image-transfer, analog-setting and advertising-name families likewise must not be used as aliases for supported high-number commands. An unsupported reply is distinct from a timeout, malformed-frame rejection or known command returning failure. Neither ID adjacency nor a familiar enum name is a basis for probing a replacement. +The U classification is version/context specific. For WHOOP 5/MG 50.42.1.0, the 88 `U` commands return result 3. Older family meanings or partial observations +do not add a command to the documented set. The legacy image-transfer, +analog-setting and advertising-name families likewise must not be used as aliases +for supported high-number commands. A result-3 reply is distinct from a timeout, +malformed-frame rejection or known command returning failure. Neither ID adjacency +nor a familiar enum name is a basis for probing a replacement. + + + + -## Core command contracts +## WHOOP 4 + +The wire command byte is at frame offset 6 in a type-35 WHOOP 4 inner record. +Requests below exclude the outer envelope. Observed compatibility behavior is not +promoted to version-specific firmware support. Response offsets and result caveats are in the +[WHOOP 4 transport profile](PROTOCOL_TRANSPORT.md#whoop-4). + +| ID | WHOOP 4 request/body | Response, effect and lifecycle | Validation and limit | +|---:|---|---|---| +| 1, 2, 4, 5 | no request documented | No observation is recorded for this version. | **U · outside the documented 41.17.6.0 command set.** | +| 3 | `00` off / `01` on in a request form observed in use | Sent by NOOP; no proprietary type-40 transition has been observed for this version. The standard BLE Heart Rate Service is separate. | **P / U · implemented outside the documented 41.17.6.0 command set.** | +| 7 | empty or legacy default | The 68-byte response body has revision 1 at offset 0, four Harvard `u32le` version components at 1, four Boylston components at 17, then 35 not-yet-named bytes. | **S · documented for 41.17.6.0.** | +| 10 | request forms observed in use: `seconds:u32le` plus four or five zeros | On some devices one of the two SET_CLOCK forms was observed to latch; read back to confirm. | **O / U · observed outside the documented 41.17.6.0 command set.** | +| 11 | request forms observed in use: empty or `00` | Use the form accepted by the device and read back the clock rather than inferring it from write acknowledgement. | **O / U · observed outside the documented 41.17.6.0 command set.** | +| 19 | exact request body unresolved | `SET_R7_REALTIME_STREAM`; this is not a haptic operation. | **S · 41.17.6.0.** Accepted options, response body and packet-transition timing remain unresolved; WHOOP 5/MG reuses ID 19 for `RUN_HAPTIC_PATTERN_MAVERICK`. | +| 20 | `00` | Aborts an open offload without acknowledging or trimming its uncommitted chunk. | **O / U · observed working in device captures on 41.17.6.0, outside the documented command set.** Restart position remains unresolved. | +| 22 | `00` | Starts asynchronous type-47 historical delivery; a response is not the data stream. | **O / U · observed working in device captures on 41.17.6.0, outside the documented command set.** Type-47 delivery was observed. | +| 23 | `01` plus exact eight-byte `HISTORY_END` block | Consumer ACK after durable commit; may permit history reclamation. | **O / U · observed working in device captures on 41.17.6.0, outside the documented command set.** `HISTORY_END` acknowledgement was observed; never reconstruct the opaque second word. | +| 26 | `00` or empty | Final charge value is `u16le / 10` percent; also used as the confirmed connection write. | **S · observed in device captures; supported by NOOP.** Validate result and body length before use. | +| 29 | no semantic fields; empty, `00` and `01` are equivalent | Returns result 1 without a body; a reboot follows. | **S · documented for 41.17.6.0.** One device observation showed no visible reboot, so the physical effect is not yet confirmed. | +| 32 | no semantic fields; empty, `00` and `01` are equivalent | Returns result 1 without a body; a power cycle follows (distinct from 29). | **S · documented for 41.17.6.0.** The physical effect remains separate from response acceptance. | +| 34 | `00` | Responses may advance response sequence while echoing one request origin. | **S · observed in device captures.** The body is not the 65-byte 50.42.1.0 layout. | +| 35 | `00` | The 131-byte body has a 10-byte serial field at body offset 14 (nine serial bytes plus NUL) and 54 bytes of key and signature material at 24. | **S · documented for this version; observed in device captures.** Sensitive material must never be exposed. | +| 63 | `00` off / `01` on | Controls the type-43 R10/R11 realtime output; 82 is not an alias. | **S · observed in device captures.** WHOOP 5/MG 50.42.1.0 returns result 3 for this ID. | +| 66 | `01 \|\| epoch_seconds:u32le \|\| subseconds:u16le` | Arms a WHOOP 4 alarm; storage acknowledgement and physical wake are separate. Working observed requests appended two zero bytes that are not evaluated. | **S · documented for 41.17.6.0; observed in device captures.** A seven-byte request was acknowledged without vibration; the semantic distinction is the subsecond field, not a longer body contract. | +| 67 | `[01]` | Reads legacy alarm state. | **S · observed in device captures; supported by NOOP.** Failure/readback variants are not exhaustive. | +| 68 | `[01]` | Starts immediate legacy alarm/haptic execution. | **S · observed in device captures; supported by NOOP.** Acceptance is not motor-movement proof. | +| 69 | `[01]` | Disables the legacy alarm, distinct from stopping an active haptic. | **S · observed in device captures; supported by NOOP.** Readback and reboot persistence remain bounded. | +| 76 | `00` | Reads the Harvard advertising name. | **S · documented for 41.17.6.0.** Complete response-field validation remains incomplete. | +| 77 | two reserved bytes, then a 16-byte name field | The first name byte must be nonzero; the last field byte is forced to NUL, leaving at most 15 name bytes. Bytes are not validated as UTF-8. | **S · documented for 41.17.6.0.** Visibility after a write is not yet confirmed in device captures. | +| 79 | five-byte preset request | Runs a legacy preset haptic pattern. | **S · observed in device captures.** Not the WHOOP 5/MG revision-1 12-byte notification pattern. | +| 81 | `[01]` | Starts WHOOP 4 raw-data output, separate from stream 63. | **S · supported by NOOP; effect not yet confirmed.** | +| 82 | `[01]` | Stops raw-data output; does not select R10/R11 stream 63. | **S · supported by NOOP; effect not yet confirmed.** Full sensor shutdown is not established. | +| 84 | legacy read request | Response fields are revision/location/confidence/status. | **S · observed in device captures.** Do not substitute the 50.42.1.0 fixed cached-status placeholders. | +| 96 | `revision_or_legacy:u8 \|\| period:u16le \|\| duration:u16le` | Enables high-frequency sync; period is at least 60 seconds and duration is at most 28,800 seconds. Returns result 1 without a body. | **S · documented for 41.17.6.0.** Event 97 reports enabled state. | +| 97 | no semantic request fields | Disables high-frequency sync and returns result 1 without a body. | **S · documented for 41.17.6.0.** Event 98 reports disabled state. | +| 98 | legacy read | With a valid cache, result 1 carries 25 bytes: `u8`, seven `u16le`, two `u32le`, then `u16le`; pack millivolts are the third `u16le` at body offset 5. | **S · documented for 41.17.6.0.** An empty cache returns result 0; this is not WHOOP 5/MG command 151. | +| 106 | `[01, state]`, where state is 0 or 1 | Sets the stored IMU data-stream state. | **S · documented for 41.17.6.0.** | +| 107 | `[01]` | Returns the stored IMU data-stream state. | **S · documented for 41.17.6.0.** The WHOOP 5/MG identifier `ENABLE_OPTICAL_DATA` does not describe this WHOOP 4 operation. | +| 117 | `[01]` | Starts feature-name enumeration and returns bounded enumeration state/count. | **S · observed in a named 41.16.6.0 device capture.** Names/layout are firmware-bound and do not establish values. | +| 118 | `[01]` repeated as cursor step | Advances feature-name enumeration; it is not an arbitrary index read. | **S · observed in a named 41.16.6.0 device capture.** End marker, exact key set and other releases remain bounded. | +| 122 | `[00]` | Stops an in-progress legacy haptic request. | **P / U · implemented outside the documented 41.17.6.0 command set.** Does not prove the WHOOP 5/MG pending/final revision-1 lifecycle. | +| 105, 123 | no request documented | No observation is recorded for this version. The wrist-selection meaning of 123 belongs to WHOOP 5/MG ECG. | **U · outside the documented 41.17.6.0 command set.** | + +### Version boundaries and negative space + +WHOOP 4 command contracts combine supported behavior and version-labelled +observations. A request, an acknowledgement and a physical effect remain +separate facts. Commands 10/11, 20, 22/23 and 122 are outside the documented +41.17.6.0 command set; their combined `O / U` or `P / U` status records observations +or implementation outside that set. Commands 20, 22 and 23 were observed working +in device captures on 41.17.6.0, including type-47 delivery and `HISTORY_END` +acknowledgement. On some devices one of the two SET_CLOCK forms was observed to +latch; read back to confirm. Commands 1, 2, 4, 5, 105 and 123 are not documented +for this version and have no recorded observation. The ECG wrist-selection meaning +of 123 belongs to WHOOP 5/MG. A WHOOP 5/MG result says nothing about the same +numeric ID on WHOOP 4. + + + + +## WHOOP 5/MG | Operation | Request and response | Effect / limits | |---|---|---| | Link check | No semantic payload fields; success, fixed 13-byte NUL-terminated acknowledgement. | Link-level acknowledgement, not device identity. | -| Live HR | Historical NOOP request byte `0` off / `1` on. | Live HR delivery is distinct from the command response; current acceptance and complete prerequisites unresolved. | -| Generic HR profile | One byte `0`/`1`; others fail. Success or failure with empty body, according to setting-write result. | Updates a nonvolatile policy; downstream standard-GATT behavior and observed restart survival unresolved. | +| Live HR | An older request uses byte `0` off / `1` on. | Live HR delivery is distinct from the command response; current acceptance and complete prerequisites unresolved. | +| Generic HR profile | One byte `0`/`1`; others fail. Success or failure with empty body, according to setting-write result. | Updates a nonvolatile policy; subsequent standard-GATT behavior and observed restart survival unresolved. | | Event delivery | One byte `0`/`1`; others fail; accepted request returns success with empty body. | Delivery toggle. Whether it selects historical events, future events or both remains unresolved; “flush stored events” is not established. | -| High-frequency sync entry | Revision 2, period `u16le`, duration `u16le`; period strictly greater than 60, duration strictly less than 28,800. | Accepted request returns success empty; invalid values fail empty. Duration is seconds; counter threshold is twice the period. [Scheduler contract](#high-frequency-sync-scheduler). | +| High-frequency sync entry | Revision 2, period `u16le`, duration `u16le`; period strictly greater than 60, duration strictly less than 28,800. | Accepted request returns success empty; invalid values fail empty. Duration is in seconds; the periodic event repeats at approximately the configured period. [Scheduler contract](#high-frequency-sync-scheduler). | | High-frequency sync exit | No semantic payload fields; success empty precedes queued disable. | Explicit exit clears active and emits event 98; automatic expiry differs. [Scheduler contract](#high-frequency-sync-scheduler). | -| History request / abort | Historical NOOP uses explicit `00` for each operation. | Start delivers metadata/records asynchronously; abort is not trim. Command 22 returns state plus two zero bytes; states 6/7/9/10 fail and others succeed, while asynchronous work is still requested. Delivery is separate. | +| History request / abort | Older requests use explicit `00` for each operation. | Start delivers metadata/records asynchronously; abort is not trim. Command 22 returns state plus two zero bytes; states 6/7/9/10 fail and others succeed, while asynchronous work is still requested. Delivery is separate. | | History acknowledgement | `01` plus the eight original HISTORY_END bytes, only after local commit. | May release stored device history. See [storage ownership](PROTOCOL_TRANSPORT.md#history-sequencing-and-storage-ownership). | | Range | No semantic request fields; initial pending empty. | Final body is 65 bytes with page cursors, estimates and clock pairs; see [range fields](PROTOCOL_TRANSPORT.md#data-range--command-34). An earlier MG returned pending then success; do not generalize all older offsets. | -| Battery | Historical NOOP uses empty or `00`; current query is asynchronous with no immediate reply established. | Older WHOOP 4 charge is `u16le / 10` percent; an older WHOOP 5 observation identified the first body byte as whole percent without establishing a universal one-byte body. The current final body is u32 whole percent; zero can be a fallback. Only a nonzero error on the ordinary completion callback carries four zero bytes; actual timeout/error handling does not guarantee that reply (see [battery responses](PROTOCOL_TRANSPORT.md#battery-level--command-26)). | +| Battery | Older requests use empty or `00`; current query is asynchronous with no immediate reply established. | Older WHOOP 4 charge is `u16le / 10` percent; an older WHOOP 5 observation identified the first body byte as whole percent without establishing a universal one-byte body. The current final body is u32 whole percent; zero can be a conversion substitute. An error reply carries four zero bytes; a measurement timeout is not confirmed to produce that reply (see [battery responses](PROTOCOL_TRANSPORT.md#battery-level--command-26)). | | Deprecated clock SET/GET | Historical SET: seconds `u32le` plus four zero subsecond bytes; WHOOP 4 also has a ninth zero variant. GET uses empty or `00`. | Current payload/reply unresolved. Separate from revision-1 high-number clocks. | -| Legacy Hello | WHOOP 4 client uses `00`. | The current local handler builds no command reply; do not expose identity fields unnecessarily. | +| Legacy Hello | The WHOOP 4 request uses `00`. | No command reply is documented for 50.42.1.0; do not expose identity fields unnecessarily. | | New Hello and clock pair | [Exact revision, size and clock precision contracts](PROTOCOL_TRANSPORT.md#clock-and-identity-contracts). | Hello final bodies are 107/111 bytes; retain their preparation flags; clock success alone does not prove nonzero valid time. | -## High-frequency sync scheduler - +### High-frequency sync scheduler Command 96 revision 2 carries revision `2`, period u16le at byte 1 and duration u16le at byte 3. Accepted values are period >60 and duration <28800. Duration is -in seconds, compared against wall-clock seconds on a later scheduler callback. -The period controls a callback-count threshold: **2 × period callbacks**. Each -callback is nominally about half a second, making period units approximately -seconds. Period 61 represents 122 received callbacks, nominally about 61 seconds -from a zero counter. Timer restart, interrupt and scheduler latency remain -separate; this is not an exact elapsed-time guarantee. +in seconds and is compared against wall-clock seconds. The period unit is +nominally about one second, so period 61 corresponds to roughly 61 seconds of +elapsed periodic interval. Scheduling latency is not bounded here; this is not an +exact elapsed-time guarantee. Entering while already active does not replace the period, duration or start time. Do not treat its successful response as confirmation that a session was refreshed. -First entry emits event 97 (`0x61`). While active, a 16-bit counter advances -once per callback; reaching twice the period emits event 96 (`0x60`) and resets -the counter. For periods 32768–65535, twice the period exceeds the counter's -maximum, so that periodic event cannot be reached by this comparison. Entry -and explicit exit do not reset the counter in these paths, so its existing -value may affect the first interval. +First entry emits event 97 (`0x61`). While active, event 96 (`0x60`) repeats at +approximately the configured period. For periods 32768–65535 the periodic event +is not emitted at all. Entry and explicit exit do not reset the elapsed period, +so the first interval after an entry can be shorter than the configured one. Command 97 emits event 98 (`0x62`) and clears active. Automatic duration expiry -clears active without that exit event. Duration zero expires on the next -callback, after the periodic-event check. Wall-clock changes and 32-bit deadline -arithmetic matter; this is not a monotonic elapsed timer. These paths schedule -event notifications; they do not establish faster Bluetooth transfer or a -changed acquisition rate. Further event consumers or client reactions are -outside this contract. Keep command IDs and event IDs in separate namespaces. - +clears active without that exit event. Duration zero expires at the next periodic +scan, after the periodic-event check. Wall-clock changes and 32-bit deadline +arithmetic matter; elapsed time is measured against the wall clock, not an +independent elapsed-time counter. +These paths schedule event notifications; they do not establish faster Bluetooth +transfer or a changed acquisition rate. Further event consumers or client +reactions are outside this contract. Keep command IDs and event IDs in separate +namespaces. -## Haptics and alarms +### Haptics and alarms -Notification haptics uses a revision-1, 12-byte body: revision at 0, eight waveform-effect bytes at 1–8, effect loop-control `u16le` at 9–10, overall-repeat byte at 11. Existing NOOP patterns use effect-loop control zero. The overall field counts repetitions **after the first pulse**, so a request for N pulses uses N−1. Older MG validation found four buzzes when that byte was 3. NOOP bounds requests to one through eight pulses; this client bound is not a universal firmware maximum. The current WHOOP 5/MG shared pattern validator requires each of the eight effect bytes to be at most 251 and the overall-repeat byte to be below 8. It does not validate the loop-control field. Passing these checks alone does not establish a valid physical waveform. The existing effect sequence and its attribution remain in the NOOP implementation; these fields do not establish every possible waveform ID. +Notification haptics uses a revision-1, 12-byte body: revision at 0, eight waveform-effect bytes at 1–8, effect loop-control `u16le` at 9–10, overall-repeat byte at 11. Observed requests use effect-loop control zero. The overall field counts repetitions **after the first pulse**, so a request for N pulses uses N−1. Older MG validation found four buzzes when that byte was 3. The documented supported range of one through eight pulses is an application bound, not a universal firmware maximum. The current WHOOP 5/MG validation requires each of the eight effect bytes to be at most 251 and the overall-repeat byte to be below 8. It does not validate the loop-control field. Passing these checks alone does not establish a valid physical waveform. A retained effect sequence and its attribution are recorded on the [implementation page](PROTOCOL_IMPLEMENTATION.md#6-commandnumber-sending--the-safe-subset); these fields do not establish every possible waveform ID. Alarm SET/GET, validation, readback, single/all-ID disable and manual RUN are described in [alarm configuration and execution](PROTOCOL_ALARMS.md). The current record is 21 bytes, -including crescendo; the earlier NOOP 20-byte encoder obtains crescendo zero from framing -padding and is not thereby shown to send a short frame. SET validation detail, storage +including crescendo; the earlier 20-byte record obtains crescendo zero from framing +padding and is not thereby shown to be a short frame. SET validation detail, storage result, execution event and physical wake are separate outcomes. RUN consumes its selected saved schedule and is not a guaranteed nondestructive preview. @@ -243,16 +357,16 @@ Older alarm arming acknowledgements remain scoped to their runs; no successful p wake is claimed. Haptic actions belong to deliberate app actions, not connection discovery. -## Service and sensitive operations +### Service and sensitive operations Pairing reset and reboot interrupt connection and work; preservation is not established for every operation. Forced trim and read-pointer changes mutate history ownership and cannot substitute for committed-chunk acknowledgement. Their recovery behavior remains unresolved. Battery-pack fields are in [transport](PROTOCOL_TRANSPORT.md#battery-pack--command-151). +### Ordinary service commands + The following operation-specific schemas are for independent interface implementations. A known field does not establish every prerequisite, safe operating sequence or completed effect. These fields do not authorize or describe a tested device update. All service-table offsets are command-body offsets; integers are little-endian. Revisioned operations use revision 1. Outer result is 1 success / 0 failure, separate from the listed body. Semantic lengths do not establish acceptance of unpadded short frames. -## Ordinary service commands - | Command | Request body | Response body | Behavior | |---:|---|---|---| | 148 | `revision:u8=1, override:u8` | `[1]` | `override=1` forces the worn state and disables normal wear detection. `0` restores normal detection. Other values fail. Restoring detection does not promise an immediate off-body report. | @@ -270,10 +384,14 @@ Command 149 updates one option within a shared stored settings record. If readin Command 141 returns revision 1 at offset 0; source/status at offset 1; a NUL-inclusive length of 1–16 at offset 2; and 16 bytes of name storage at offsets 3–18. Status 1 identifies a custom name. Status 2 covers fallback or empty storage and storage-read failure; it does not diagnose a specific storage error. The last storage byte is zero. Limit decoding to this fixed storage and exclude the terminator from display. -## Image-transfer command boundaries - -See [image transfer](PROTOCOL_UPDATES.md#image-transfer-command-boundaries). + + -## Certificate command boundaries +### Image-transfer and certificate commands -See [certificates and authorization](PROTOCOL_UPDATES.md#certificate-command-boundaries). +Commands 83 and 142–144 transfer and verify a firmware image; their request bodies, +response details and container fields are specified in +[image transfer](PROTOCOL_UPDATES.md#image-transfer-command-boundaries). Commands +155–159 cover certificate transfer and device authorization; NOOP implements no +update or unlock path, and the details are outside this reference. See +[certificates and authorization](PROTOCOL_UPDATES.md#certificate-command-boundaries). diff --git a/docs/PROTOCOL_CONCEPTS.md b/docs/PROTOCOL_CONCEPTS.md index f4755f2e79..7ff04b9212 100644 --- a/docs/PROTOCOL_CONCEPTS.md +++ b/docs/PROTOCOL_CONCEPTS.md @@ -23,20 +23,20 @@ A frame is a self-delimiting byte string beginning with a Start-Of-Frame marker a CRC32 trailer. The two generations share the CRC32 payload check but differ in the header checksum. Select the family before parsing: -| Family | Header check | Enum (`HeaderCRCKind`) | -|--------|--------------|------------------------| -| `whoop4` | CRC8 (poly `0x07`) | `.crc8` | -| `whoop5` | CRC16-Modbus (poly `0xA001`, init `0xFFFF`, reflected) | `.crc16Modbus` | +| Family | Header check | +|--------|--------------| +| WHOOP 4 | CRC8 (poly `0x07`) | +| WHOOP 5/MG | CRC16-Modbus (poly `0xA001`, init `0xFFFF`, reflected) | ## Checksums -| Algorithm | Function | Parameters | -|-----------|----------|------------| -| CRC8 | `crc8(_:)` | table-driven, poly `0x07`, init `0x00` | -| CRC32 (zlib) | `crc32(_:)` | reflected, poly `0xEDB88320`, init `0xFFFFFFFF`, final XOR `0xFFFFFFFF` | -| CRC16-Modbus | `crc16Modbus(_:)` | poly `0xA001`, init `0xFFFF`, reflected | +| Algorithm | Parameters | +|-----------|------------| +| CRC8 | table-driven, poly `0x07`, init `0x00` | +| CRC32 (zlib) | reflected, poly `0xEDB88320`, init `0xFFFFFFFF`, final XOR `0xFFFFFFFF` | +| CRC16-Modbus | poly `0xA001`, init `0xFFFF`, reflected | Validate the complete frame before decoding or updating state. CRC checks detect corruption; they are not cryptographic authentication. Generation-specific length diff --git a/docs/PROTOCOL_CONFIGURATION.md b/docs/PROTOCOL_CONFIGURATION.md index e811e4733f..7da85e6b20 100644 --- a/docs/PROTOCOL_CONFIGURATION.md +++ b/docs/PROTOCOL_CONFIGURATION.md @@ -2,9 +2,123 @@ Applicability: [central scope and compatibility](PROTOCOL.md#scope-and-compatibility). -This is the configuration companion to [the protocol reference](PROTOCOL.md). Unless explicitly labeled historical, the contracts below apply to the reference baseline. A stored value, its interpreted policy, accepted request, effective sensor activity and delivered packet stream are different states. Do not collapse them into one “enabled” boolean. Device-specific factory values, power-interruption survival and all hardware variants are not established by these contracts. - -## Named configuration interface +This is the configuration companion to [the protocol reference](PROTOCOL.md). + +## Contents + +- [WHOOP 4](#whoop-4) + - [WHOOP 4 state model](#whoop-4-state-model) + - [WHOOP 4 legacy controls](#whoop-4-legacy-controls) + - [WHOOP 4 feature-name enumeration](#whoop-4-feature-name-enumeration) + - [WHOOP 4 configuration gaps](#whoop-4-configuration-gaps) +- [WHOOP 5/MG](#whoop-5mg) + - [Named configuration interface](#named-configuration-interface) + - [Value types and storage](#value-types-and-storage) + - [Device key inventory](#device-key-inventory) + - [Feature flag inventory](#feature-flag-inventory) + - [Collection, storage and live transport](#collection-storage-and-live-transport) + - [Other sensor configuration](#other-sensor-configuration) + - [AFE parameters (61/62)](#afe-parameters-6162) + - [Signal-processing configuration (138)](#signal-processing-configuration-138) + - [Gyro mode (150/152)](#gyro-mode-150152) + - [Configuration reads — commands 121 and 128](#configuration-reads--commands-121-and-128) + - [Collection settings and overlapping controls](#collection-settings-and-overlapping-controls) + - [Analog configuration readback](#analog-configuration-readback) + - [Collection and live-stream coordination](#collection-and-live-stream-coordination) + - [R22 version preferences](#r22-version-preferences) + + + +## WHOOP 4 + +WHOOP 4 does not inherit the 50.42.1.0 named-key descriptors, 65-byte SET/GET +records, AFE selector semantics, R20/R21 persistent policies or gyro-mode contract. +WHOOP 4 has separate contracts, plus a complete +41.17.6.0 support classification for IDs 1–132. AFE, raw/record routing, device +configuration and feature-flag operations are present, but this does not import +WHOOP 5/MG record shapes or make every WHOOP 4 wire contract complete. + +In particular, WHOOP 4 command 105 is outside the documented 41.17.6.0 command +set and has no recorded observation. Command 106 sets IMU stream +state with `[01, state]`, and command 107 reads it with `[01]`; command 63 +independently controls R10/R11 realtime output. WHOOP 4 raw +collection, live transport, historical saving and persistent policy must remain +separate states just as on WHOOP 5/MG, but their bytes must come from the +[WHOOP 4 command profile](PROTOCOL_COMMANDS.md#whoop-4), not from +the revision-1 50.42.1.0 bodies. A feature-name enumeration reports names, not +values, defaults, support for writes, or successful physical application. + +### WHOOP 4 state model + +For each control keep these states separate: + +1. request bytes constructed by a client; +2. write accepted by the Bluetooth stack; +3. correlated command result returned by the strap; +4. value read back, where a read operation exists; +5. output/saving behavior observed in packets; and +6. persistence after reconnect or reboot. + +Confirmation of an earlier state does not establish later states. + +### WHOOP 4 legacy controls + +| Area | Operation | WHOOP 4 status | Known boundary | +|---|---|---|---| +| Live HR | command 3, request form `01`/`00` observed in use | **Implemented outside the documented 41.17.6.0 command set** | NOOP sends the proprietary command, but no type-40 transition has been observed for this version; standard HRS remains separate | +| R10/R11 realtime | command 63, body `01`/`00` | **Device capture + supported behavior** | controls observed type-43 output; command 82 did not stop that output | +| Raw collection | commands 81/82, body `01` | **Older request convention** | collection intent is distinct from live transport; persistence and exact storage effect unresolved | +| IMU modes | commands 105–107 | **105 outside the documented set; 106/107 supported SET/GET operations on 41.17.6.0** | 106 uses `[01, state]`; 107 uses `[01]` and returns stored state; the WHOOP 5/MG identifier `ENABLE_OPTICAL_DATA` does not describe this WHOOP 4 operation | +| Analog front end | commands 39–44 and 61/62 | **Supported on 41.17.6.0** | channel plus value/parameter operands are documented; widths, units, ranges and safe values remain incomplete | +| Body placement | command 123 | **Outside the documented 41.17.6.0 command set** | no observation is recorded for this version; wrist selection under this ID belongs to WHOOP 5/MG ECG | + +Further supported operations cover record send/save/persistence (46–65, 70–72, +129–132), raw start/stop (81/82), device-config enumeration/set/get +(115/116/119/121), and feature-flag enumeration/set/get (117/118/120/128). +Known fields include channel/parameter/value, revision checks, cursor indices and +success/failure states. Exact request lengths, complete namespaces, authorization +and persistence remain unresolved. + +### WHOOP 4 feature-name enumeration + +Commands 117 and 118 form a bounded, read-only enumeration sequence. +Command 117 starts/resets a feature enumeration and 118 advances a shared +cursor. A WHOOP 4 R19-era observation +returned feature names, while at least one response value field was contaminated +by a stale shared buffer. Consequently: + +- treat returned names as bounded strings and preserve the raw response; +- serialize requests because the cursor is shared state; +- stop by an explicit terminal condition or client limits, not by one malformed name; +- do not interpret an adjacent byte as the feature's current value without an + independently mapped response layout; +- do not infer write support, default, polarity, persistence or sensor effect. + +The observed name inventory belongs to its captured firmware and is not a complete +41.17.6.0 descriptor table. The WHOOP 5/MG 65-byte named-key SET/GET records below +must not be used against WHOOP 4 based on a matching name alone. + +### WHOOP 4 configuration gaps + +Still open are the supported revision table, complete +feature/device-key inventories, value encodings, storage schema, factory defaults, +write authorization, validation ranges, the order in which a stored value is +applied, and reboot survival. A safe implementation exposes only capture-backed reads and already +proven operational controls; it preserves unknown results rather than probing by +write. + + + +## WHOOP 5/MG + +Unless explicitly labeled historical, the contracts below apply to the **WHOOP +5/MG 50.42.1.0 version baseline**. A stored value, its interpreted policy, +accepted request, effective sensor activity and delivered packet stream are +different states. Do not collapse them into one “enabled” boolean. Device-specific +factory values, power-interruption survival and all hardware variants are not +established by these contracts. + +### Named configuration interface | Operation | Command | Request / response boundary | |---|---|---| @@ -17,23 +131,28 @@ This is the configuration companion to [the protocol reference](PROTOCOL.md). Un | Read device value | 121 | Revision 1 followed by a 32-byte key field; 65-byte reply. | | Read feature value | 128 | Same request and reply sizes, feature namespace. | -The current named SET body is **65 bytes** before outer framing/padding. Fields are NUL-terminated; names have at most 31 meaningful bytes in the 32-byte field. Use ASCII values matching the descriptor type below and zero-fill unused field bytes. The shorter historical NOOP encoder, with one value character and a few padding bytes, does not establish the complete request contract. Handling of truncated SET bodies is unresolved; do not use them as capability probes. +The current named SET body is **65 bytes** before outer framing/padding. Fields are NUL-terminated; names have at most 31 meaningful bytes in the 32-byte field. Use ASCII values matching the descriptor type below and zero-fill unused field bytes. A shorter historical request, with one value character and a few padding bytes, does not establish the complete request contract. Handling of truncated SET bodies is unresolved; do not use them as capability tests. -Enumeration replies expose revision, index, validity and a bounded name; they do not return values. A false validity byte alone is not a universal terminator. Earlier NOOP handling recognizes index 255 as terminal and uses bounded count/slack and empty-response limits. The baseline count is one byte; older formats remain separately scoped. NOOP's safeguards—128 responses maximum, eight consecutive empty entries, and announced count plus four—are client limits, not promised firmware capacities. Serialize enumeration requests because the cursor is stateful. +Enumeration replies expose revision, index, validity and a bounded name; they do not return values. A false validity byte alone is not a universal terminator. Index 255 is the observed terminal value; the baseline count is one byte and older formats remain separately scoped. Firmware capacity and behavior after repeated empty entries are unresolved. Enumeration requests are sequential because the cursor is shared state. -GET reads configuration storage rather than merely echoing the preceding SET. The current reply is the 65-byte key/value record specified below, including its failure bodies. Validate response command, origin, result, bounds and returned key before using a value. Preserve the bounded value bytes instead of taking the final padded byte as a boolean. A response timeout is not evidence that a named key or command does not exist. +GET reads configuration storage rather than merely echoing the preceding SET. The current reply is the 65-byte key/value record specified below, including its failure bodies. Validate response command, origin, result, bounds and returned key before using a value. Preserve the bounded value bytes instead of taking the final padded byte as a boolean. A response timeout does not mean that a named key or command does not exist. -### Value types and storage +#### Value types and storage | Type | Accepted representation / conversion | Meaning boundary | |---|---|---| | Tri-state | Exact NUL-terminated ASCII `0`, `1` or `2` | Numeric policy values, not one universal on/off encoding. | -| Unsigned byte | Decimal integer bounded above by 255 | The parser limit is not a per-key safe operating range. | +| Unsigned byte | Decimal integer bounded above by 255 | The accepted value range is not a per-key safe operating range. | | Tenths | Nonnegative decimal value, scaled by 10 with `+0.5` rounding into bounded integer storage; readback decodes a 16-bit quantity | `max_collection_backlog` uses tenths. It expresses capacity percentage in tenths of a percentage point; safe operating thresholds remain unknown. | -Device and feature namespaces are stored separately in checked nonvolatile records. A named SET rewrites the namespace's five records; atomicity of those writes is not established. A failed record read substitutes zero-filled data. Thus zero readback may be a fallback, not a successfully recovered saved value, and neither proves a factory default. Per-key callback effects and reboot behavior are not uniformly established. Read back a changed value when the relevant reply contract is supported, and separately observe the intended application effect. +Keep request acceptance, readback, visible effect and persistence as separate +states. A named SET can be acknowledged even when its later visible effect or +reboot persistence is not established. A failed read can yield zero-filled data, +so zero readback can be a fallback rather than a confirmed saved value or factory +default. Read back a changed value when the relevant reply contract is supported, +then separately observe the intended device behavior and reboot survival. -## Device key inventory +### Device key inventory All eight names below are eligible for named lookup. Eligibility does not establish a complete behavioral contract or support on every device. @@ -45,28 +164,30 @@ All eight names below are eligible for named lookup. Eligibility does not establ | `cont_collection_mode` | Unsigned byte | Contributes to optical/IMU collection policy alongside session and persistent requests. Mode 0 removes continuous collection; 1 requests optical and IMU collection. Other accepted bytes are not established supported modes. | | `whoop_live_hr_in_adv_ind_pkt` | Tri-state | Stored 1 selects this advertising preference before the two-HRM preference; 0/2 do not. Exact advertised contents and timing remain unresolved. | | `whoop_live_2_hrm_devices` | Tri-state | Stored 1 selects the named preference; 0/2 do not. Exact connection capacity and factory state remain unresolved. | -| `enable_raw_data_w_ecg` | Tri-state | Resolver: `0` and `1` true, `2` false; fallback is true. Requests companion raw optical/IMU handling after successful ECG startup. It is **not** the ECG master gate. | +| `enable_raw_data_w_ecg` | Tri-state | Values `0` and `1` act as true, `2` as false; unavailable readback also acts as true. Enables companion raw optical/IMU handling after successful ECG startup. It is **not** the ECG master gate. | | `dorset_detection_period_min` | Unsigned byte | Zero or a failed read selects fallback 5; a nonzero value uses the stored byte. The name does not independently establish time units or a safe range. | -In particular, writing zero is not a universal reset-to-off operation. The ECG companion resolver defaults on even with zero-filled storage. See [ECG behavior](PROTOCOL_ECG.md) for its startup and independent live/save controls. +In particular, writing zero is not a universal reset-to-off operation. ECG companion +collection remains enabled when readback is zero or unavailable. See [ECG behavior](PROTOCOL_ECG.md) +for its startup and independent live/save controls. -## Feature flag inventory +### Feature flag inventory -All 25 descriptors use the tri-state representation. Twenty are eligible for named lookup; five are not. Ineligible names are included so an application does not mistake a readable name or historical write bundle for a supported named SET. They may still have an internal role. For entries without a decoded consumer below, polarity, applied default, packet effect and hardware variation remain **unknown**, regardless of how suggestive the name is. +All 25 descriptors use the tri-state representation. Twenty are eligible for named lookup; five are not. Ineligible names are included so an application does not mistake a readable name or historical write bundle for a supported named SET. For entries without a decoded consumer below, polarity, applied default, packet effect and hardware variation remain **unknown**, regardless of how suggestive the name is. | Feature key | Named lookup | Known meaning / limit | |---|---|---| | `general_ab_test` | Ineligible | No public operational value mapping established. | -| `enable_r22_packets` | Eligible | Historical packet 47/layout 22 master: `1` permits, `0` and `2` do not. Internal variants/readiness remain separate. | -| `enable_r22_v2_packets` | Eligible | Consumer maps 1 to true, 0/2 to false; see [version selection](#r22-version-preferences). | -| `enable_r22_v3_packets` | Eligible | Consumer maps 1 to true, 0/2 to false; see [version selection](#r22-version-preferences). | -| `enable_r22_v4_packets` | Eligible | Consumer maps 1 to true, 0/2 to false; see [version selection](#r22-version-preferences). | -| `enable_r22_v5_packets` | Eligible | Consumer maps 1 to true, 0/2 to false; see [version selection](#r22-version-preferences). | -| `enable_r22_v6_packets` | Eligible | Consumer maps 1 to true, 0/2 to false; see [version selection](#r22-version-preferences). | +| `enable_r22_packets` | Eligible | Historical packet 47/layout 22 master: `1` permits, `0` and `2` do not. Further readiness conditions are documented separately. | +| `enable_r22_v2_packets` | Eligible | A stored 1 is treated as true; 0 and 2 as false. See [version selection](#r22-version-preferences). | +| `enable_r22_v3_packets` | Eligible | A stored 1 is treated as true; 0 and 2 as false. See [version selection](#r22-version-preferences). | +| `enable_r22_v4_packets` | Eligible | A stored 1 is treated as true; 0 and 2 as false. See [version selection](#r22-version-preferences). | +| `enable_r22_v5_packets` | Eligible | A stored 1 is treated as true; 0 and 2 as false. See [version selection](#r22-version-preferences). | +| `enable_r22_v6_packets` | Eligible | A stored 1 is treated as true; 0 and 2 as false. See [version selection](#r22-version-preferences). | | `enable_r22_v8_packets` | Eligible | Per-version selector semantics unresolved. | -| `enable_r22_v9_packets` | Eligible | Consumer maps 1 to true, 0/2 to false; see [version selection](#r22-version-preferences). | +| `enable_r22_v9_packets` | Eligible | A stored 1 is treated as true; 0 and 2 as false. See [version selection](#r22-version-preferences). | | `make_hrfm_visible` | Eligible | Consumer semantics unresolved. | -| `disable_pip_r26_packets` | Eligible | Inverse historical packet 47/layout 26 permission: `1` removes it; `0` and `2` permit it. Producer readiness is additionally required; this is not a global optical stop. | +| `disable_pip_r26_packets` | Eligible | Inverse historical packet 47/layout 26 permission: `1` removes it; `0` and `2` permit it. Record availability is additionally required; this is not a global optical stop. | | `wear_detect_bias` | Eligible | Consumer semantics unresolved. | | `enable_pdaf_walk_det` | Ineligible | No named SET support established. | | `enable_maverick_model` | Ineligible | No named SET support established. | @@ -84,34 +205,45 @@ All 25 descriptors use the tri-state representation. Twenty are eligible for nam The historical write of ASCII `2` to the R22 master must not be described as universally enabling R22. In this version it removes that master contribution. Likewise the inverse-named PIP flag must not be presented with the same permission polarity. Consumer truth does not establish emitted variants, and v8 remains outside this mapping; do not guess an all-features bundle. Preserve unknown record variants instead of forcing them through a known decoder. -## Collection, storage and live transport +### Collection, storage and live transport Requests below are command bodies, excluding the transport envelope. Revision-1 boolean controls take `[1, state]` with `state` 0 or 1; other boolean values are rejected. Live transport and persistent settings are separate from shared session requests; overlapping writers require the ordering rules below. | Control | Commands | Contract and application effect | |---|---|---| -| Raw producer start/stop | 81 / 82 | Revision 1 supported; start also has a revision-2 path whose body remains unresolved. Start/stop is separate from live transport and saving. | -| IMU session saving | 105 | Revision-1 boolean; changes a session collection contribution in RAM. It does not write the persistent policy below. | +| Raw collection start/stop | 81 / 82 | Revision 1 supported; start also accepts a revision-2 request whose body remains unresolved. Start/stop is separate from live transport and saving. | +| IMU session saving | 105 | Revision-1 boolean; changes the current session's collection state. It does not establish persistent policy. | | IMU live transport | 106 | Revision-1 boolean; controls live IMU delivery independently of saved records. | -| Optical session saving | 107 | Revision-1 boolean; separate RAM collection contribution. | +| Optical session saving | 107 | Revision-1 boolean; changes the current session's optical saving state. The identifier is historical; on WHOOP 5/MG 50.42.1.0 the operation controls optical session saving, not live optical output (that is 108). | | Optical live transport | 108 | Revision-1 boolean; independently controls live optical delivery. | | Persistent optical/R20 policy | 153 | Revision-1 boolean. Wire 0 stores explicit off, wire 1 stores on. Nonvolatile write requested, but ACK does not check programming success. | | Persistent IMU/R21 policy | 154 | Same persistent policy contract, independent of IMU session saving. | -The two dedicated persistent policies share an option record with LED accessibility (149). If the setter cannot read that record, it writes from defaults before applying the requested option, so other stored options may be replaced. This is separate from the named configuration namespaces below. +Commands 149, 153 and 154 affect related persistent options. If the existing +options cannot be read, changing one can make other options return to defaults. +This is separate from the [named configuration namespaces](#named-configuration-interface) +above. -The persistent policy resolver treats stored 1 as true and stored 0/2 as false. A missing or invalid record falls back to stored zero; a deployed device may already contain other values. There is no established BLE getter for these two dedicated policies. The durable-write path is distinct from RAM session flags, but observed reboot survival, power-loss atomicity and actual successful programming are not guaranteed by a command acknowledgement. +For the two dedicated policies, readback behavior treats 1 as true and 0 or 2 as +false; missing or invalid saved state appears as zero. There is no established BLE +getter for these policies. An acknowledgement does not establish successful +persistence, reboot survival or power-loss atomicity. -Raw, individual-sensor and ECG companion controls write shared session requests in event order; they are not independent leases. A later raw stop can clear requests set by another session control. Persistent and continuous sources remain separate contributions. Full downstream hardware application is unresolved; “off acknowledged” is not proof that the sensor is idle. Live transport, saving and producer start/stop each need their own application state. Startup/reset defaults and disconnect behavior are not universally known. +Raw, individual-sensor and ECG companion controls take effect in request order; +a later raw stop can clear collection requested earlier by another session control. +Persistent and continuous settings remain separate. An acknowledged off request +does not prove that the sensor is idle. Track live output, saving, collection and +persistence separately; startup, reset and disconnect behavior are not universally +known. The earlier live-IMU sequence starts raw production before enabling live IMU transport; stopping production and disabling that transport are separate cleanup operations. Its 1,244-byte frame contains 100 six-axis samples. This is a versioned example, not a frame size to hard-code for every record. See [sensor layouts](PROTOCOL_SENSORS.md) and [raw capture operations](RAW_DATA_CAPTURE.md). -## Other sensor configuration - -### AFE parameters (61/62) +### Other sensor configuration +#### AFE parameters (61/62) -The request and response structure is 12 bytes, with **no revision prefix**: +The request and response structure for the [MAX86176 front end](PROTOCOL_WHOOP5.md#whoop5-max86176) +is 12 bytes, with **no revision prefix**: | Offset | Width | Field | |---|---|---| @@ -124,10 +256,10 @@ physical unit or range: individual settings may normalize nonzero to 1, truncate to a byte, or run their own validation/conversion. Both commands are gated by subsystem state. Check the response result before interpreting its body as readback; failed responses can retain the input words. Successful -readback describes the cached configuration, not proof of analog register -application or reboot persistence. SET queues configuration processing after -dispatch even when dispatch reports an error; its response-refresh read is not -independently checked. +readback describes the cached configuration, not proof that the analog front end +was reconfigured or that the value survives a reboot. A failure result does not +prove that nothing changed, and the value in a response is not independently +re-read from hardware. For generic per-channel settings, canonical channel values are 1–6; lookup uses their low byte. Do not rely on ignored upper bits. The numeric selector @@ -136,49 +268,42 @@ inventory is: | Setting | Established contract | |---|---| | 1–5, 7–12 | Per-channel values with field-specific conversion/validation; physical labels, units and safe ranges unresolved | -| 6, 22 | Unsupported dispatch | +| 6, 22 | Settings 6 and 22 are not accepted | | 13 | Per-channel boolean; nonzero normalizes to 1 | | 14–19 | The same channel boolean, selecting channels 1,2,4,5,6,3 respectively regardless of the channel word | | 20 | Per-channel byte value; SET narrows to u8 | | 21, 23 | Separate global booleans; user-facing functions unresolved | | 24 | Low-byte selector 0 or 1; stored/readback value remains that selector; physical interpretation unresolved | -Setting zero and other unhandled full-word selectors fail dispatch. These +Setting zero and other unlisted selectors fail. These contracts support encoding and decoding; they do not supply a safe analog tuning interface or authorize guessing wavelengths, current, gain or defaults. - -### Signal-processing configuration (138) +#### Signal-processing configuration (138) Signal-processing configuration takes revision 1 and a value byte. All byte values receive success; 0–8 select defined presets with unresolved meanings, while 9–255 do not select a new preset. Storage and reconfiguration are still attempted, and success does not prove persistence. See [service contracts](PROTOCOL_COMMANDS.md#ordinary-service-commands). -### Gyro mode (150/152) +#### Gyro mode (150/152) -Commands 150/152 use revision 1; SET takes a following boolean byte. +Commands 150/152 target the [ICM-45686 IMU](PROTOCOL_WHOOP5.md#whoop5-icm-45686) +and use revision 1; SET takes a following boolean byte. +Command 150 argument 0 selects gyro disabled and argument 1 selects enabled. +Command 152 reports the current mode value; it does not prove current sample output. -Command 150 argument 0 requests gyro disabled and argument 1 requests enabled. -The operating-mode register is written and read back before the cached mode is -updated. FIFO reconfiguration then runs as a separate stage. Command 152 -reports whether the cached mode equals the enabled mode; it does not perform a -fresh physical read or validate FIFO configuration. +**A failed gyro SET can still have changed the mode; read back after a failure.** +A failed SET can report failure while GET already returns the new value. Show +uncertain device state; neither success nor failure is an atomic rollback guarantee, +and a changed GET does not establish live gyroscope output. Enable/disable events +115/116 are emitted only after the setting is fully applied. -**A failed SET can already have changed the mode.** If FIFO reconfiguration -fails after the mode write succeeded, the command reports failure while the -cache already contains the new mode. A failure during the earlier mode stage -can also follow a physical write whose readback could not be verified. Read -back after failure and show uncertain application state; neither success nor -failure is an atomic rollback guarantee, and a changed GET does not establish -that the FIFO stage succeeded. Enable/disable events 115/116 are emitted only -after both stages succeed. +The strap can select enabled mode during startup, but that is not a guaranteed +final boot state or a user's saved preference. Persistence across reboot, actual +power/sample behavior and later collection-state changes remain separate. -A successful initialization path requests the enabled mode, but that is not a -guaranteed final boot state or a user's saved preference. The traced operation -changes sensor registers and cached state. Persistence across reboot, actual -power/sample behavior and later collection-state overrides remain separate. + - -## Configuration reads — commands 121 and 128 +### Configuration reads — commands 121 and 128 Command 121 reads device configuration; 128 reads feature flags. The request is revision 1 followed by a 32-byte key. Use at most 31 key bytes plus NUL padding. @@ -195,11 +320,11 @@ On SUCCESS, the value is text: depending on the key's type, it can be `0`, `1`, digit. No binary value or type tag is present. Key-specific meaning and units must come from the key's schema. -Unknown keys, reported value-read helper errors and formatting failures return +Unknown keys, value-read errors and formatting failures return result 0, the normalized key echo and an all-zero value field. An underlying -checked-storage-slot read failure is different: the loader substitutes zero bytes -and continues successfully. A valid key can therefore return result 1 with a -formatted zero fallback despite such a storage failure. Unsupported request revisions +storage-slot read failure is different: an unreadable stored value is returned as +zeros with result 1. A valid key can therefore return result 1 with a formatted +zero fallback despite such a storage failure. Unsupported request revisions return result 0 with revision 1 and 64 zero bytes, without a key echo. Accept a value only after checking result 1 and matching the canonical key. A non-NUL 32-byte key is normalized and therefore will not be echoed exactly. @@ -208,7 +333,7 @@ Enumeration-start commands 115 and 117 use revision 1. Their successful body is two bytes: revision 1 and a **one-byte entry count**. Invalid revision returns FAILURE with `01 00`. They reset separate enumeration cursors. -## Collection settings and overlapping controls +### Collection settings and overlapping controls `cont_collection_mode=0` removes the continuous collection request; `1` requests both optical and IMU collection. Other nonzero byte values contribute an optical request without the mode-1 IMU @@ -258,8 +383,7 @@ processing state untouched. A stored zero is a consumer fallback, not a factory reset. No factory value, reconnect survival or reboot outcome is established by these additions. - -## Analog configuration readback +### Analog configuration readback Commands 61 and 62 carry three little-endian u32 words: channel, setting and value. Successful readback describes cached configuration. It does not confirm that @@ -280,19 +404,18 @@ For example, setting 2 input 100 with setting 3 equal to 20 reads back as 120. Changing setting 3 to 30 changes setting 2 readback to 130. These are numeric interface rules; physical units and safe tuning values are not established. -Configuration application is deferred and can stop after earlier driver -operations have run. Its pending indicator is cleared before application, so a -cleared indicator does not guarantee success or an automatic retry. Treat -command acceptance, cached readback and complete physical application as separate -states. Preserve uncertainty after an application failure. - +A successful reply does not prove that the setting is in effect; read back, and +treat command acceptance, cached readback and complete physical application as +separate states. An application failure is not retried automatically. Preserve +uncertainty after an application failure. -## Collection and live-stream coordination +### Collection and live-stream coordination Requested policy selection prioritizes a true persistent preference, then the shared raw request, then the individual sensor session request, and finally -continuous collection. Contributor counting, active-state transitions and backlog -guards are separate; selected policy does not prove completed physical acquisition. +continuous collection. Multiple collection requests can coexist, and backlog can +delay a requested policy change; selected policy does not prove completed physical +acquisition. Raw collection can hold a shared collection request in addition to the individual optical and motion requests. Turning off an individual request, or stopping ECG @@ -303,27 +426,26 @@ requested afterward. Treat the operations as overlapping mutable controls and reconcile their resulting state. Live motion output has separate requested and active states. A requested change -is applied later through a driver operation. Failure can leave the previous -active output state in place. Rapidly sending an enable followed by a disable +is applied later. Failure can leave the previous active output state in place. Rapidly sending an enable followed by a disable before application can also leave output enabled despite the last requested value being disabled. Serialize opposite changes and check actual output; an acknowledgment or requested-state readback alone does not confirm application. -Cold sensor initialization clears the temporary collection and live-stream +A sensor restart clears the temporary collection and live-stream requests in this version. That does not establish which reset operations execute initialization, whether persistent preferences are reasserted, or what remains active after a radio disconnect. Loss of notifications is not -evidence that sensing or historical recording stopped. Explicitly reconcile +proof that sensing or historical recording stopped. Explicitly reconcile collection and ECG state after reconnecting. `enable_r22_packets` gates historical packet 47/layout 22. The inverse `disable_pip_r26_packets` flag removes historical packet 47/layout 26 publication permission; it is not a global -optical acquisition stop. The latter path also requires producer readiness. +optical acquisition stop. The latter path also requires record availability. Unresolved experimental settings should remain opaque: names alone do not prove physical effects, deployed defaults, safe values or support for additional wire formats. -## R22 version preferences +### R22 version preferences R22 preparation chooses enabled version preferences in this order: **9, 6, 5, 4, 3, 2, then 1 as fallback**. The master R22 flag gates preparation diff --git a/docs/PROTOCOL_ECG.md b/docs/PROTOCOL_ECG.md index e6147c1e18..bd7db2c5c2 100644 --- a/docs/PROTOCOL_ECG.md +++ b/docs/PROTOCOL_ECG.md @@ -5,6 +5,8 @@ Applicability: [central scope and compatibility](PROTOCOL.md#scope-and-compatibi This chapter specifies the ECG interface applicable to the reference baseline. It complements [the main protocol reference](PROTOCOL.md) and separates the versioned sensor records from earlier generic “Labrador” payload hypotheses. +MG ECG uses the ECG channel of the optical front end; see the +[hardware overview](PROTOCOL_WHOOP5.md#whoop5-max86176). A shared firmware version does not guarantee ECG hardware, successful initialization, or availability on every strap. The complete workflow and physical waveform calibration have not been validated on hardware for this version. @@ -13,6 +15,20 @@ All offsets below refer to the **complete reassembled format-1 frame**, starting at its framing byte. Check framing, declared length and both checksums before reading a record. Multi-byte fields are little-endian unless explicitly stated. +## Contents + +- [Commands and independent output gates](#commands-and-independent-output-gates) +- [Repeated ECG start and companion collection](#repeated-ecg-start-and-companion-collection) +- [Routing and shared header](#routing-and-shared-header) +- [Packed status: bytes 21–33](#packed-status-bytes-2133) +- [R17 filtered waveform](#r17-filtered-waveform) + - [Output ratio and conditional count bound](#output-ratio-and-conditional-count-bound) +- [R16 raw waveform and lead diagnostics](#r16-raw-waveform-and-lead-diagnostics) +- [Startup, settling and interpretation](#startup-settling-and-interpretation) +- [Front-end application and calibration boundary](#front-end-application-and-calibration-boundary) +- [Decoder and session requirements](#decoder-and-session-requirements) +- [Constructed parser checks](#constructed-parser-checks) + ## Commands and independent output gates Payloads start at the command revision byte, after the command envelope. Every @@ -29,11 +45,11 @@ command in this table requires revision `01`. Response result `1` means success; | 139 / 8B | Send filtered ECG live | `01 00` off; `01 01` on | Independently gates filtered live output. | The four output toggles accept only arguments zero and one. Generation control -and these toggles can be rejected by a hardware compatibility guard; passing that -guard does not satisfy every initialization prerequisite. Wrist selection has a -separate subsystem-state condition. It updates the active in-memory selection; -retention across reboot is **not established**. Do not encode right/left as zero/one -on this version or promise a persistent selection. +and these toggles can be rejected when the required hardware capability is absent; +having that capability does not satisfy every startup prerequisite. Wrist selection +has a separate subsystem-state condition. It updates the current selection; +persistence is **not established**. Do not encode right/left as zero/one on this +version or promise a persistent selection. Generation and output are distinct. An enabled live gate does not start the ECG front end. A start ACK precedes fallible initialization and conversion setup, so it @@ -44,9 +60,9 @@ The device setting `enable_raw_data_w_ecg` controls **accompanying historical optical and IMU requests**, after ECG startup succeeds. It is not a master ECG permission. Its stored values resolve as `1 = true`, `2 = false`, and `0 = true`; its unavailable/read-failure fallback is also true. Successful ECG generation can -continue when the setting is false. Stopping ECG requests companion shutdown only while its bookkeeping still records -those requests. Repeated starts can discard that bookkeeping; see the lifecycle -condition below. A raw session can still retain its separate shared request. This interaction matters if the app also manages +continue when the setting is false. Stopping ECG requests companion shutdown only while the session still records +those companion requests. A repeated start can drop that record; see the +lifecycle condition below. A raw session can still retain its separate shared request. This interaction matters if the app also manages optical/IMU collection independently: these controls write shared session requests in event order, so a later raw stop can clear ECG companion requests. Persistent and continuous sources can still contribute. A stored setting, an ACK and effective collection are different states. @@ -62,14 +78,13 @@ Do not equate silence with absent electrode contact or a completed session. For command 124 revision 1, both arguments 2 and 3 request ECG startup. Neither is an idempotent ensure-running operation. When accompanying optical/IMU collection -was enabled by an earlier successful ECG start, another start can discard the -bookkeeping used to turn those companion requests off. This can happen if the -new initialization fails, or if it succeeds with the companion option now off. -A subsequent ECG stop can then omit its usual companion-off requests. +was enabled by an earlier successful ECG start, another start can cause a later +ECG stop to omit the companion-off requests. This is observed when the later start +fails or when it succeeds with the companion option off. Serialize ECG session transitions and resolve the previous session before -starting another. Reconcile shared raw/optical/IMU requests as part of the app's -session management; those controls do not provide independent ownership leases. +starting another. Reconcile shared raw/optical/IMU requests as part of client +session management; the controls are not independent leases. A stop response does not establish that all queued sensor changes have completed. This ordering condition does not establish ongoing physical acquisition or a particular power effect. @@ -112,20 +127,16 @@ records solely by that pair. This is a **13-byte packed region**, not a 17-byte structure of unpacked booleans. Preserve unknown codes and raw bytes alongside any derived presentation. -The described quality handler produces numeric codes 0–3. Reset clears presence -and quality; later transitions can set presence with quality 1, then quality 2 or -3. Another transition clears both. These are partial state-machine outcomes, not -an exhaustive enum or a bad/good/excellent scale. Keep presence separate from -clinical signal quality and preserve unknown codes. - -In the described quality-input path, consecutive nonzero per-input flags increment -an unsigned counter capped at 65535; zero resets that run counter. Once the count -exceeds 100, it submits a transition input that, from the quality handler's state -0, sets presence and quality to 1. These counts are processed input entries, not -milliseconds. Later checks in the same iteration can submit further inputs, so -this does not guarantee the next transmitted status or readiness. The handler's -internal state 0 is distinct from the wire classifier fields below; nonzero flags -are not an established electrical-contact or clinical-quality classification. +Observed quality codes are 0–3. Presence and quality can clear together, then +presence can appear with quality 1 followed by quality 2 or 3. These are partial +observed outcomes, not an exhaustive enum or a bad/good/excellent scale. Keep +presence separate from clinical signal quality and preserve unknown codes. + +A sustained run of nonzero per-input quality flags is required before presence +and quality first report 1; a zero flag restarts that run. Its duration is counted +in processed samples, not milliseconds, and reaching it does not guarantee what +the next transmitted status will be. Nonzero flags are not an established +electrical-contact or clinical-quality classification. | Offset | Width | Meaning and limitation | |---:|---:|---| @@ -172,10 +183,10 @@ larger counts as anomalies. Do not silently truncate them. Read only the declare number of samples, even though every frame reserves all 100 slots. Zero-valued samples can be meaningful; nonzero detection is not a substitute for the count. -these slots contain **signed i16 little-endian** +In this version, these slots contain **signed i16 little-endian** values. Physical scale remains unresolved; signed numerical representation does -not supply a voltage calibration. A gating condition can deliberately insert -zero-valued samples into the output queue. Counted zeros therefore remain entries +not supply a voltage calibration. A device condition can produce zero-valued +samples. Counted zeros therefore remain entries and must not be discarded as padding or used alone to conclude that generation stopped. Do not assume that the final conversion guarantees a saturating amplitude clamp. @@ -185,14 +196,14 @@ Sample capacity and notification cadence do not establish the sample rate. ### Output ratio and conditional count bound -The standard startup configuration emits one filtered queue value per five -processed input samples. This is a count ratio, not an independently established +The standard observed configuration emits one filtered value per five processed +input samples. This is a count ratio, not an independently established sample rate. With at most 500 accepted raw inputs per update, normal grouping -state and an empty output queue before that update, at most 100 filtered entries +and no retained filtered values before that update, at most 100 filtered entries are produced. This bound assumes no concurrent or intervening configuration change. -The retrieval queue nevertheless has 250 slots and no final 100-entry clamp. -Backlog, alternate configuration and other scheduling states are not covered by +The strap can retain up to 250 filtered values before delivery and does not apply +a final 100-entry limit there. Backlog, alternate configuration and other timing states are not covered by that conditional bound. Continue rejecting or quarantining a declared R17 count above 100. The normal-path explanation does not enlarge the frame's capacity or justify silently truncating an anomalous count. @@ -228,7 +239,7 @@ flag7 = (b0 >> 7) & 1 ``` This is an 18-bit payload plus flags, not little-endian i16 and not a signed 24-bit -sample. Retain bits 2–5 as uninterpreted reserved bits. the waveform uses **signed two's-complement 18-bit coding**: after reconstructing +sample. Retain bits 2–5 as uninterpreted reserved bits. On the wire, the waveform uses **signed two's-complement 18-bit coding**: after reconstructing `raw18`, values below 131072 remain unchanged; values at or above 131072 subtract 262144. The numerical range is **−131072 through 131071**. Keep flag bits 6/7 separate; they are not sign bits. This establishes coding, not volts per count. @@ -256,41 +267,6 @@ The I/Q halfwords have a signed diagnostic interpretation but no established physical units. Preserve raw words as well as an optional signed view. Do not use their sign or magnitude as a clinical threshold. -## Decoder and session requirements - -An implementation integrating this contract needs: - -1. Version-aware wrist encoding and explicit support for all four live/save gates. -2. Packet type **and** layout selection, exact frame size, checksum checks and count - bounds before sample extraction. -3. Separate R16/R17 status parsers, fixed capacities and padding handling; no - fallback to the generic 17-byte Labrador header for these revisions. -4. Raw values retained for unknown enum codes, flag semantics, waveform units and - timestamp fields. No medical label inferred from a classifier field name. -5. Distinct session outcomes for command failure, acknowledged initialization, - waveform reception and stop/cleanup. Status changes alone are not samples. - -NOOP's earlier right/left zero/one mapping, 101-halfword filtered slice, and generic -count-based raw payload interpretation are incompatible with these versioned -contracts. This document specifies the required behavior; it does not assert that -all application paths already implement it. - -Still unresolved are physical voltage scale, raw/filtered rates, an unconditional -valid runtime filtered-count bound, complete hardware prerequisites, contact acceptance, -classifier-code semantics and clinical validity. Neither 500 raw slots nor 100 -filtered slots establishes a rate, and no fixed session duration is established. - -## Constructed parser checks - -Run `python3 docs/protocol-examples/validate_examples.py` from the repository root. -The [standalone example](protocol-examples/validate_examples.py) checks R17 count -bounds and padding exclusion, signed i16/i18 edge cases, raw18/flag separation and -the specified 500/10 contact-index boundaries using constructed values. Its buffers -deliberately have no valid transport framing or checksums. These checks exercise -the documented arithmetic; they do not independently validate device behavior, -NOOP integration, the universal filtered-count bound or physical calibration. - - ## Startup, settling and interpretation Select the intended wrist and check the command response before starting ECG @@ -305,19 +281,12 @@ actual generation using validated revision-specific ECG records and their declar sample counts. Command refusal, no incoming records, valid zero-valued samples and status changes are distinct observations. -Valid counted samples can be zero during initial settling or later signal -rejection. The standard startup gate begins at 179 processed-input iterations. With the -normal initial selection phase and no extension, the first 36 selected outputs -are counted zeros and the next selected output is at input 181. These conditional -counts are not milliseconds or a guaranteed startup waveform. Settling can be -extended by subsequent input conditions. A zero per-input flag proposes 930 -remaining processed-input iterations; an input at or below the configured signed -lower bound, or at or above the upper bound, proposes 680; an absolute step at or -above its configured threshold proposes 280. A candidate -replaces the remaining count only when larger. Other holdoffs control which checks -run, and these checks precede the block output-loop countdown. These are processing -counts, not milliseconds or electrode/clinical thresholds. Do not infer -readiness from a fixed delay, or classify a session solely from a flat waveform. +Early samples can be reported as zeros while the front end settles; no fixed +settling time is documented. Valid counted samples can also be zero during later +signal rejection, and settling can be extended by subsequent signal conditions, +so a flat opening does not bound how long the session takes to produce a +waveform. Do not infer readiness from a fixed delay, or classify a session solely +from a flat waveform. Preserve numeric quality, presence, state and classifier fields; their clinical meaning is not established here. Preserve unknown values for later interpretation. @@ -329,18 +298,51 @@ the live/save gates enabled for the session. A calibration also requires the effective clock, gain and reference configuration and the complete conversion from input codes through processing to output values. -Register definitions alone would not establish that chain. Buffer capacity is not +Knowing the front end's configuration fields alone would not establish that chain. Buffer capacity is not a sample frequency, and numerical scaling constants alone do not identify volts. ## Front-end application and calibration boundary -ECG initialization attempts front-end register writes and compares readback bytes. -That comparison does not check every underlying transport return status. Software +An accepted start does not prove the analog front end was configured. Reported initialization success or a cached configuration value therefore does not prove -that all requested hardware settings took effect. A frame-divider change writes -two ordered bytes and can fail partway through. +that all requested hardware settings took effect, and a multi-byte front-end +change can fail partway through. The numerical divider, FIFO count and one-in-five filtered selection do not establish absolute sample frequency or voltage scale. FIFO items can have different tags; an item count is not necessarily an ECG-only sample count. Use sample index and native amplitude unless a matching calibration is available. + +## Decoder and session requirements + +An implementation integrating this contract needs: + +1. Version-aware wrist encoding and explicit support for all four live/save gates. +2. Packet type **and** layout selection, exact frame size, checksum checks and count + bounds before sample extraction. +3. Separate R16/R17 status parsers, fixed capacities and padding handling; no + fallback to the generic 17-byte Labrador header for these revisions. +4. Raw values retained for unknown enum codes, flag semantics, waveform units and + timestamp fields. No medical label inferred from a classifier field name. +5. Distinct session outcomes for command failure, acknowledged initialization, + waveform reception and stop/cleanup. Status changes alone are not samples. + +The earlier right/left zero/one mapping, 101-halfword filtered slice, and generic +count-based raw payload interpretation are incompatible with these versioned +contracts. This document specifies the wire behavior without asserting support in +every application path. + +Still unresolved are physical voltage scale, raw/filtered rates, an unconditional +valid runtime filtered-count bound, complete hardware prerequisites, contact acceptance, +classifier-code semantics and clinical validity. Neither 500 raw slots nor 100 +filtered slots establishes a rate, and no fixed session duration is established. + +## Constructed parser checks + +Run `python3 docs/protocol-examples/validate_examples.py` from the repository root. +The [standalone example](protocol-examples/validate_examples.py) checks R17 count +bounds and padding exclusion, signed i16/i18 edge cases, raw18/flag separation and +the specified 500/10 contact-index boundaries using constructed values. Its buffers +deliberately have no valid transport framing or checksums. These checks exercise +the documented arithmetic; they do not independently validate device behavior, +application integration, the universal filtered-count bound or physical calibration. diff --git a/docs/PROTOCOL_IMPLEMENTATION.md b/docs/PROTOCOL_IMPLEMENTATION.md index 5372aeceef..935c7246e3 100644 --- a/docs/PROTOCOL_IMPLEMENTATION.md +++ b/docs/PROTOCOL_IMPLEMENTATION.md @@ -9,16 +9,196 @@ contracts use the [topic index](PROTOCOL.md#reading-guide); the legacy tables he must not override it. - [Client command inventory](#6-commandnumber-sending--the-safe-subset) -- [Probes and their limits](#whoop-40-reboot-probe-235) +- [Probes and their limits](#whoop-4-reboot-probe-235) - [Offload state machine](#73-session-state-machine) - [Decoded output](#8-decoded-output-parsedframe) - [SpO₂ observation and import boundaries](#10-spo₂-on-50--mg--what-the-wire-does-and-does-not-carry) - [Implementation file map](#11-file-map) +## Protocol contract to implementation map + +The protocol pages define the wire contracts; the tables below show where NOOP +implements each one. Every code reference names both a file and a symbol, and CI +checks that both continue to exist. + +### Transport + +| Contract | Swift | Android | Note | +|---|---|---|---| +| [WHOOP 4 envelope](PROTOCOL_WHOOP4.md#whoop-4-envelope) | [verifyFrame(_:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift), [crc8(_:_:_:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift), [crc32(_:_:_:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift) | [Framing.frameCrcOk](../android/app/src/main/java/com/noop/protocol/Framing.kt), [Crc.crc8](../android/app/src/main/java/com/noop/protocol/Crc.kt), [Crc.crc32](../android/app/src/main/java/com/noop/protocol/Crc.kt) | CRC8 protects the length; CRC32 protects the inner record | +| [WHOOP 5 format 1](PROTOCOL_TRANSPORT.md#format-1-framing) | [verifyFrame(_:family:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift), [crc16Modbus(_:_:_:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift), [crc32(_:_:_:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift) | [Framing.frameCrcOk](../android/app/src/main/java/com/noop/protocol/Framing.kt), [Crc.crc16Modbus](../android/app/src/main/java/com/noop/protocol/Crc.kt), [Crc.crc32](../android/app/src/main/java/com/noop/protocol/Crc.kt) | Format is selected before offsets or checksums | +| [Fragment reassembly](PROTOCOL_TRANSPORT.md#format-1-framing) | [Reassembler](../Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift) | [Reassembler](../android/app/src/main/java/com/noop/protocol/Framing.kt) | One family-aware bounded buffer per connection | +| [Response correlation](PROTOCOL_TRANSPORT.md#responses-and-correlation) | [parseFrame(_:family:collectFields:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift), [Whoop4ResponseResultTests](../Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop4ResponseResultTests.swift) | [Framing.parseFrame](../android/app/src/main/java/com/noop/protocol/Framing.kt), [Whoop4ResponseResultTest](../android/app/src/test/java/com/noop/protocol/Whoop4ResponseResultTest.kt) | Origin sequence and result code are decoded separately | +| [CLIENT_HELLO](PROTOCOL_WHOOP5.md#connection-and-frame-format) | [DeviceFamily.clientHello](../Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceFamily.swift) | [DeviceFamily.clientHello](../android/app/src/main/java/com/noop/protocol/DeviceFamily.kt) | Fixed frame is family metadata | +| [WHOOP 4 bond and handshake](PROTOCOL_WHOOP4.md#bond-handshake--connect-lifecycle-whoop-40) | [peripheral(_:didWriteValueFor:error:)](../Strand/BLE/BLEManager.swift) | [onCharacteristicWrite](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt), [runConnectHandshake](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt) | Confirmed write establishes the client bond state | + +### Identity + +| Contract | Swift | Android | Note | +|---|---|---|---| +| [Hello 35 serial window](PROTOCOL_WHOOP4.md#get_hello_harvard-35-response--the-whoop-40-serial) | [Whoop4HelloSerial.decode(payload:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop4HelloSerial.swift) | [Whoop4HelloSerial.decode](../android/app/src/main/java/com/noop/protocol/Whoop4HelloSerial.kt) | Reads only the fixed nine-byte window | +| [Two-hello confirmation](PROTOCOL_WHOOP4.md#get_hello_harvard-35-response--the-whoop-40-serial) | [RepeatedSerialGate.offer(_:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop4HelloSerial.swift) | [RepeatedSerialGate.offer](../android/app/src/main/java/com/noop/protocol/Whoop4HelloSerial.kt) | Adoption waits for the same serial twice | +| [DIS 5.0/MG resolver](PROTOCOL_WHOOP5.md#whoop-50-vs-mg--telling-the-hardware-apart) | [Whoop5Variant.from(serial:hardwareRevision:modelNumber:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Variant.swift) | [Whoop5Variant.from](../android/app/src/main/java/com/noop/protocol/Whoop5Variant.kt) | Contradictory inputs resolve to unknown | +| [Hello 145](PROTOCOL_TRANSPORT.md#hello--command-145) | [parseFrame(_:family:collectFields:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift) | [Framing.parseFrame](../android/app/src/main/java/com/noop/protocol/Framing.kt) | Decodes device name and firmware version | + +### Commands + +| Contract | Swift | Android | Note | +|---|---|---|---| +| [Curated sender enum](PROTOCOL_COMMANDS.md#canonical-command-matrix) | [WhoopCommand](../Strand/BLE/Commands.swift) | [CommandNumber](../android/app/src/main/java/com/noop/protocol/Enums.kt) | Sender surface is intentionally smaller than the decode catalogue | +| [WHOOP 4 command builder](PROTOCOL_TRANSPORT.md#whoop-4-frame-and-response-procedure) | [WhoopCommand.frame(seq:payload:)](../Strand/BLE/Commands.swift) | [Framing.buildCommand](../android/app/src/main/java/com/noop/protocol/Framing.kt) | Builds type 35 with the WHOOP 4 envelope | +| [WHOOP 5 command builder](PROTOCOL_TRANSPORT.md#format-1-framing) | [puffinCommandFrame(cmd:seq:payload:type:header:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift) | [Framing.puffinCommandFrame](../android/app/src/main/java/com/noop/protocol/Framing.kt) | Pads the inner record before checksums | +| [Clock 8/9-byte forms](PROTOCOL_COMMANDS.md#whoop-4) | [setClockPayload(now:)](../Strand/BLE/BLEManager.swift), [setClockPayloadLegacy(now:)](../Strand/BLE/BLEManager.swift) | [setClockPayload](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt), [setClockPayloadLegacy](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt) | WHOOP 4 sends both accepted lengths | +| [Alarm 9-byte body](PROTOCOL_ALARMS.md#scheduled-alarm-lifecycle-on-whoop-4) | [WhoopCommand.setAlarmPayload(epochSec:)](../Strand/BLE/Commands.swift) | [whoop4AlarmPayload](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt) | Two trailing bytes remain explicit | +| [Advertising name](PROTOCOL_COMMANDS.md#whoop-4) | [WhoopCommand.advertisingNamePayload(_:)](../Strand/BLE/Commands.swift) | [renameStrap](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt) | WHOOP 4 only; client bytes are bounded | +| [Haptic preset](PROTOCOL_ALARMS.md#immediate-whoop-4-haptics) | [MaverickHaptics.notificationBuzz(loops:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/HapticPayloads.swift) | [maverickHapticBody](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt) | Android remaps the common request to the family body | +| [Wrist and ECG controls](PROTOCOL_ECG.md#commands-and-independent-output-gates) | [Whoop5Ecg.selectWristPayload(_:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift), [Whoop5Ecg.togglePayload(on:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift) | [Whoop5Ecg.selectWristPayload](../android/app/src/main/java/com/noop/protocol/Whoop5Ecg.kt), [Whoop5Ecg.togglePayload](../android/app/src/main/java/com/noop/protocol/Whoop5Ecg.kt) | MG capability gate is separate from framing | +| [Feature-flag and device-config probes](PROTOCOL_CONFIGURATION.md#named-configuration-interface) | [FeatureFlagProbe](../Packages/WhoopProtocol/Sources/WhoopProtocol/FeatureFlagProbe.swift), [DeviceConfigReadProbe](../Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift) | [FeatureFlagProbe](../android/app/src/main/java/com/noop/protocol/FeatureFlagProbe.kt), [DeviceConfigReadProbe](../android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt) | Read paths are bounded and namespace-aware | +| [Reboot probe](PROTOCOL_COMMANDS.md#unsupported-and-cross-version-commands) | [RebootProbeVariant](../Strand/BLE/Commands.swift) | [RebootProbeVariant](../android/app/src/main/java/com/noop/protocol/Enums.kt) | User-initiated candidate set only | +| [Send allowlist gate](PROTOCOL_COMMANDS.md#compatibility-status) | [send(_:payload:writeType:)](../Strand/BLE/BLEManager.swift), [DeviceConfigWriteGate.admitsSend(opcode:payload:ecgGateOptIn:isMG:broadcastHrOptIn:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigWriteGate.swift) | [send](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt), [DeviceConfigWriteGate.admitsSend](../android/app/src/main/java/com/noop/protocol/DeviceConfigWriteGate.kt) | Raw WHOOP 5 sends are checked before framing | + +### History + +| Contract | Swift | Android | Note | +|---|---|---|---| +| [Offload start](PROTOCOL_TRANSPORT.md#history-sequencing-and-storage-ownership) | [beginBackfill](../Strand/BLE/BLEManager.swift) | [beginBackfill](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt) | Start is connection- and state-gated | +| [Offload abort](PROTOCOL_TRANSPORT.md#interruption-and-recovery) | [abortBackfill](../Strand/BLE/BLEManager.swift) | [abortBackfill](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt) | Abort does not advance trim | +| [HISTORY_END decoder](PROTOCOL_WHOOP4.md#history_end-payload-layout) | [classifyHistoricalMeta(_:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift) | [classifyHistoricalMeta](../android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt) | END and COMPLETE remain distinct states | +| [ACK with end block](PROTOCOL_TRANSPORT.md#whoop-4-history-lifecycle) | [finishChunk(unix:trim:endFrame:)](../Strand/Collect/Backfiller.swift) | [finishChunk](../android/app/src/main/java/com/noop/ble/Backfiller.kt) | The exact eight-byte end block is retained | +| [Safe-trim invariant](PROTOCOL_TRANSPORT.md#history-sequencing-and-storage-ownership) | [finishChunk(unix:trim:endFrame:)](../Strand/Collect/Backfiller.swift) | [finishChunk](../android/app/src/main/java/com/noop/ble/Backfiller.kt) | A failed durable write withholds the ACK | +| [Persist before ACK](PROTOCOL_TRANSPORT.md#history-sequencing-and-storage-ownership) | [Backfiller](../Strand/Collect/Backfiller.swift) | [Backfiller](../android/app/src/main/java/com/noop/ble/Backfiller.kt) | Each platform owns its transaction ordering | +| [Data range 34](PROTOCOL_TRANSPORT.md#data-range--command-34) | [DataRange.newestUnix(from:wallNowUnix:futureSkewSeconds:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift), [DataRange.oldestUnix(from:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift) | [DataRange.newestUnix](../android/app/src/main/java/com/noop/protocol/DataRange.kt), [DataRange.oldestUnix](../android/app/src/main/java/com/noop/protocol/DataRange.kt) | Bounds are decoded independently | +| [Ring backlog](#get_data_range-ring-backlog-689-diagnostic-only) | [DataRange.pagesBehind(from:cmdOff:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift) | [DataRange.pagesBehind](../android/app/src/main/java/com/noop/protocol/DataRange.kt) | Both decode validated u32 ring pointers | + +### Records + +| Contract | Swift | Android | Note | +|---|---|---|---| +| [Type 40](PROTOCOL_SENSORS.md#whoop-4-realtime-heart-rate-record-type-40) | [registerPostHooks()](../Packages/WhoopProtocol/Sources/WhoopProtocol/PostHooks.swift) | [Framing.parseFrame](../android/app/src/main/java/com/noop/protocol/Framing.kt) | Live HR and R-R share the framed record path | +| [Type 43 variants 1917/1921](PROTOCOL_SENSORS.md#whoop-4-realtime-raw-layouts-type-43) | [registerPostHooks()](../Packages/WhoopProtocol/Sources/WhoopProtocol/PostHooks.swift) | — | Swift selects the WHOOP 4 layout by payload length; Android does not dispatch type 43 | +| [Type 47 v24/v25 and legacy layouts](PROTOCOL_SENSORS.md#whoop-4-historical-v25-and-unknown-versions) | [historicalLayoutSupport(version:observedLength:family:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalLayoutSupport.swift), [extractHistoricalStreams(_:deviceClockRef:wallClockRef:family:wallNow:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalStreams.swift) | [decodeHistorical](../android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt), [extractHistoricalStreams](../android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt) | Unknown layouts remain fail-closed | +| [R18](PROTOCOL_SENSORS.md#r18-biometric-summary) | [decodeWhoop5Historical(_:fb:payloadEnd:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift) | [decodeWhoop5Historical](../android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt) | Biometric summary uses the versioned record decoder | +| [R20](PROTOCOL_SENSORS.md#r20-optical-blocks) | [decodeWhoop5Historical(_:fb:payloadEnd:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift) | [decodeWhoop5Historical](../android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt) | Optical blocks are decoded by record version | +| [R21](PROTOCOL_SENSORS.md#r21-six-axis-imu) | [decodeWhoop5HistoricalV2021(_:fb:version:payloadEnd:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift) | [decodeWhoop5HistoricalV2021](../android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt) | Six inertial channels use explicit offsets | +| [R22 versions](PROTOCOL_SENSORS.md#r22-inner-version) | — | — | No NOOP R22 record decoder is implemented | +| [R26](PROTOCOL_SENSORS.md#r26-compact-optical-window) | [decodeWhoop5HistoricalV26(_:fb:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift) | [decodeWhoop5HistoricalV26](../android/app/src/main/java/com/noop/protocol/HistoricalStreams.kt) | Compact optical window has a dedicated layout | +| [ECG R16/R17](PROTOCOL_ECG.md#routing-and-shared-header) | [Whoop5Ecg](../Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift) | [Whoop5Ecg](../android/app/src/main/java/com/noop/protocol/Whoop5Ecg.kt) | Raw and filtered routes share the status header | +| [IMU streams 51/52](PROTOCOL_SENSORS.md#dedicated-imu-stream-types-51-and-52) | [Whoop5RawImu.decode(_:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift) | [Whoop5RawImu.decode](../android/app/src/main/java/com/noop/protocol/Whoop5RawImu.kt) | Dedicated buffers decode to six-axis samples | +| [Battery 26](PROTOCOL_TRANSPORT.md#battery-level--command-26) | [parseFrame(_:family:collectFields:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift), [registerPostHooks()](../Packages/WhoopProtocol/Sources/WhoopProtocol/PostHooks.swift) | [Framing.parseFrame](../android/app/src/main/java/com/noop/protocol/Framing.kt) | WHOOP 4 uses u16/10; WHOOP 5 replies with four bytes, but both decoders currently use only the low byte | +| [Battery pack 151](PROTOCOL_TRANSPORT.md#battery-pack--command-151) | [BatteryPackInfo.decode(frame:cmdOff:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/BatteryPackInfo.swift) | [BatteryPackInfo.decode](../android/app/src/main/java/com/noop/protocol/BatteryPackInfo.kt) | Reply and event forms share the record decoder | +| [Extended battery event 63](PROTOCOL_TRANSPORT.md#whoop-4-battery-sources) | [registerPostHooks()](../Packages/WhoopProtocol/Sources/WhoopProtocol/PostHooks.swift) | [Framing.parseFrame](../android/app/src/main/java/com/noop/protocol/Framing.kt) | WHOOP 4 event fields are decoded separately from command 26 | + +### Configuration + +| Contract | Swift | Android | Note | +|---|---|---|---| +| [Named-key SET/GET 119–121/128](PROTOCOL_CONFIGURATION.md#named-configuration-interface) | [DeviceConfigReadProbe](../Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift), [DeviceConfigWriteGate](../Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigWriteGate.swift) | [DeviceConfigReadProbe](../android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt), [DeviceConfigWriteGate](../android/app/src/main/java/com/noop/protocol/DeviceConfigWriteGate.kt) | Writes require key-aware admission and readback | +| [Feature-flag enumeration 117/118](PROTOCOL_CONFIGURATION.md#feature-flag-inventory) | [FeatureFlagProbe.parseStart(frame:family:namespace:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/FeatureFlagProbe.swift), [FeatureFlagProbe.parseNext(frame:family:namespace:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/FeatureFlagProbe.swift) | [FeatureFlagProbe.parseStart](../android/app/src/main/java/com/noop/protocol/FeatureFlagProbe.kt), [FeatureFlagProbe.parseNext](../android/app/src/main/java/com/noop/protocol/FeatureFlagProbe.kt) | Cursor walk is bounded | +| [R22 disable sequence](PROTOCOL_CONFIGURATION.md#r22-version-preferences) | [R22DisableReport](../Packages/WhoopProtocol/Sources/WhoopProtocol/R22Disable.swift), [FeatureFlagWriteGate](../Packages/WhoopProtocol/Sources/WhoopProtocol/R22Disable.swift) | [R22DisableReport](../android/app/src/main/java/com/noop/protocol/R22Disable.kt), [FeatureFlagWriteGate](../android/app/src/main/java/com/noop/protocol/R22Disable.kt) | Clear writes are followed by per-key verification | +| [AFE 61/62](PROTOCOL_CONFIGURATION.md#afe-parameters-6162) | [CommandNumber](../Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json) | [CommandNames](../android/app/src/main/java/com/noop/protocol/Enums.kt) | Decode catalogue only; no sender builder | +| [Gyro 150/152](PROTOCOL_CONFIGURATION.md#gyro-mode-150152) | — | — | No NOOP sender or decoder is implemented | +| [Collection policies 153/154](PROTOCOL_CONFIGURATION.md#collection-settings-and-overlapping-controls) | [CommandNumber](../Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json) | [CommandNames](../android/app/src/main/java/com/noop/protocol/Enums.kt) | Named in the decode catalogue; no sender builder | + +### Alarms and haptics + +| Contract | Swift | Android | Note | +|---|---|---|---| +| [SET/GET/RUN/DISABLE 66–69](PROTOCOL_ALARMS.md#scheduled-alarm-lifecycle-on-whoop-4) | [armStrapAlarm(at:)](../Strand/BLE/BLEManager.swift), [getStrapAlarm()](../Strand/BLE/BLEManager.swift), [buzzStrapOnce()](../Strand/BLE/BLEManager.swift), [disableStrapAlarm()](../Strand/BLE/BLEManager.swift) | [armStrapAlarm](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt), [getStrapAlarm](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt), [buzzStrapOnce](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt), [disableStrapAlarm](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt) | BLE-client entry points select the family-specific payload revision | +| [STOP 122](PROTOCOL_ALARMS.md#busy-execution-and-stop-completion) | [WhoopCommand.stopHaptics](../Strand/BLE/Commands.swift) | [stopHaptics](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt) | Stop is explicit on supported paths | +| [Pattern 19/79](PROTOCOL_ALARMS.md#immediate-whoop-4-haptics) | [MaverickHaptics.notificationBuzz(loops:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/HapticPayloads.swift), [WhoopCommand.runHapticsPattern](../Strand/BLE/Commands.swift) | [maverickHapticBody](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt), [CommandNumber.RUN_HAPTIC_PATTERN_MAVERICK](../android/app/src/main/java/com/noop/protocol/Enums.kt) | Common request is remapped for WHOOP 5/MG | + +### Updates and authorization + +| Contract | Swift | Android | Note | +|---|---|---|---| +| [Update and authorization boundaries](PROTOCOL_UPDATES.md) | — | — | NOOP has no installation path on either platform | + +### Diagnostic probes + +| Contract | Swift | Android | Note | +|---|---|---|---| +| [Reboot 29/32](#whoop-4-reboot-probe-235) | [RebootProbeVariant](../Strand/BLE/Commands.swift) | [RebootProbeVariant](../android/app/src/main/java/com/noop/protocol/Enums.kt) | Candidate frames are user-selected | +| [Body location 84](#body-location-probe-690) | [BodyLocationProbe.format(frame:cmdOff:isWhoop5:prevPayloadHex:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/BodyLocationProbe.swift) | [formatBodyLocationProbe](../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt) | Read-only formatted response | +| [Feature flag 761](#feature-flag-enumeration-probe-761-read-only) | [FeatureFlagProbe](../Packages/WhoopProtocol/Sources/WhoopProtocol/FeatureFlagProbe.swift) | [FeatureFlagProbe](../android/app/src/main/java/com/noop/protocol/FeatureFlagProbe.kt) | Enumeration stops on bounds or terminal response | +| [Device config 103](#device-config-read-probe-103-read-only) | [DeviceConfigReadProbe](../Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift) | [DeviceConfigReadProbe](../android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt) | Read-only namespace probe | +| [Ring backlog 689](#get_data_range-ring-backlog-689-diagnostic-only) | [DataRange.pagesBehind(from:cmdOff:)](../Packages/WhoopProtocol/Sources/WhoopProtocol/DataRange.swift) | [DataRange.pagesBehind](../android/app/src/main/java/com/noop/protocol/DataRange.kt) | Diagnostic estimate, not a stored-record count | + + + +## WHOOP 4 + +WHOOP 4 behavior is version-scoped. NOOP implementation, observed wire bytes, +command acknowledgement, persisted state and physical device effects are separate +facts. For example, the seven-byte semantic alarm body is implemented with two +additional unevaluated zero bytes observed in working requests, while acknowledgement +alone still does not prove a scheduled physical buzz. + +### Generation-specific implementation map + +| Concern | WHOOP 4 implementation rule | +|---|---| +| Discovery | Use the `61080001-…` Harvard service and its family-specific characteristics; do not infer support from Puffin/Monument/Symphony advertisement alone | +| Framing | Select WHOOP 4 before reading length/CRC offsets; complete size is `u16le@1 + 4`, with CRC8 header and inner CRC32 | +| Reassembly | Keep fragment buffers connection-scoped, discard leading garbage, bound declared sizes and reset state on disconnect | +| Correlation | Match command, origin sequence and connection generation; preserve pending/final and duplicate CRC-valid responses | +| Records | Dispatch type first, then layout version or validated length; never apply WHOOP 5/MG offsets by a constant shift | +| History | Commit rows, rejected layouts and cursor before acknowledging the exact eight-byte END block | +| Battery | Prefer proprietary command/event observations over the standard Battery Service stub | +| Configuration | Keep request acceptance, readback, producer activity and persistence as separate facts | +| Alarms | Use the seven-byte revision-1 SET body; NOOP appends the two unevaluated zero bytes observed in working requests. Readback is diagnostic and recurrence remains application-owned | +| Updates | NOOP has no documented or authorized installation/flash path | + +### Fail-closed and preserve-raw behavior + +Malformed length, CRC failure and truncated fields are local +parse failures, not wire result codes. Unknown packet types, command results and +record versions should retain bounded raw data. An unknown historical layout +must not be acknowledged as successfully stored merely because its outer CRC is +valid; the local transaction must first preserve enough data for later +retro-decoding. + +WHOOP 4 fallback decoding is deliberately asymmetric. Known v5/7/9, v12/v24 and +v25 layouts use explicit maps. Other historical versions may enter a v24-shaped +compatibility decoder only when physiological plausibility gates pass; otherwise +decoded fields are removed. This is client behavior and must be logged as fallback, +not relabelled as a proven version map. + +### Remaining WHOOP 4 implementation gaps + +Incomplete areas include request/response bodies for many supported commands, +record layouts for several revisions, the complete Nordic BLE/SPI contract, +configuration storage, alarm lifecycle and update authorization/recovery. NOOP +does not provide or authorize firmware installation. + +### NOOP connection policy + +These are NOOP client choices around the [WHOOP 4 connect sequence](PROTOCOL_WHOOP4.md#bond-handshake--connect-lifecycle-whoop-40), +not protocol requirements. + +- NOOP marks the connection bonded once the confirmed `GET_BATTERY_LEVEL` write is + acknowledged, then runs the connect handshake. +- The handshake runs exactly once per connection. Its one-shot guard + (`connectHandshakeDone`) is load-bearing: the write-acknowledgement callback that + starts it fires again for every later confirmed write, and re-blasting the + handshake mid-offload was the historical root cause of the strap refusing to + stream type-47. +- `GET_HELLO_HARVARD` (35) mirrors the official app's flow; it is not strictly + required before the strap will serve data. +- The first historical offload starts about 1.5 s after `GET_DATA_RANGE`, so the + link settles first. +- A 15-minute backfill timer (`backfillIntervalSeconds`, matching WHOOP) and a + 30-second keep-alive timer (`keepAliveIntervalSeconds`: re-arm realtime, poll + battery, watchdog the link) are then started. With Low refresh enabled the + backfill interval is 60 minutes (`lowRefreshBackfillIntervalSeconds`). +- The Swift path retains `GET_CLOCK`-based device/wall-clock correlation for WHOOP 4. + Android derives its correlation from `GET_DATA_RANGE`. Commands 10/11 remain + outside the documented 41.17.6.0 command set; on some devices one retained + SET_CLOCK form was observed to latch, so both clients read back or correlate + independently rather than trusting the write acknowledgement. ## Diagnostic-only WHOOP service families -The official app also models additional WHOOP service families with the same `0001` service plus +Additional WHOOP service families use the same `0001` service plus `0002`/`0003`/`0004`/`0005`/`0007` characteristic pattern. NOOP lists these as protocol metadata and logs them when advertised, but does not connect, discover characteristics, or send commands for them until the correct framing is mapped and hardware-tested. @@ -108,7 +288,9 @@ a placeholder header makes every rebuilt frame fail, so the rebuild now round-tr -## PacketType (offset `[4]`, or `[8]` on 5.0) + + +## PacketType (offset `[4]`, or `[8]` on WHOOP 5/MG) This is NOOP’s schema vocabulary, not a guarantee that every named packet is produced by either generation. Current WHOOP 5/MG layouts are in [sensor records](PROTOCOL_SENSORS.md). @@ -116,10 +298,10 @@ Source: `enums.PacketType` in `whoop_protocol.json`; resolved by `Schema.typeNam | Value | Name | Notes | |------:|------|-------| -| 35 | `COMMAND` | outbound command (app → strap) | -| 36 | `COMMAND_RESPONSE` | reply to a command | -| 37 | `PUFFIN_COMMAND` | WHOOP 5.0 command | -| 38 | `PUFFIN_COMMAND_RESPONSE` | WHOOP 5.0; aliased → `COMMAND_RESPONSE` | +| 35 | `COMMAND` | format-1 outbound command (app → strap) | +| 36 | `COMMAND_RESPONSE` | format-1 reply to a command | +| 37 | `PUFFIN_COMMAND` | older label; role in 50.42.1.0 not confirmed | +| 38 | `PUFFIN_COMMAND_RESPONSE` | older label; role in 50.42.1.0 not confirmed; aliased → `COMMAND_RESPONSE` | | 40 | `REALTIME_DATA` | live HR / R-R | | 43 | `REALTIME_RAW_DATA` | live raw sensor data; the reference baseline also carries ECG R16/R17 ([ECG](PROTOCOL_ECG.md)); older ~1.9 KB IMU/optical examples are not a universal layout | | 47 | `HISTORICAL_DATA` | offloaded biometric records | @@ -133,9 +315,10 @@ Source: `enums.PacketType` in `whoop_protocol.json`; resolved by `Schema.typeNam | 55 | `RELATIVE_BATTERY_PACK_CONSOLE_LOGS` | | | 56 | `PUFFIN_METADATA` | WHOOP 5.0; aliased → `METADATA` | -`isOffloadFrame(_:)` (in `BLEManager`) treats **47/48/49/50** as offload traffic; the live -`REALTIME_DATA`(40)/`REALTIME_RAW_DATA`(43) flood is excluded so it cannot keep the backfill -idle-watchdog alive. +Swift `isOffloadFrame(_:)` treats **47/48/49/50/56** as offload traffic. Android +uses the same list and additionally classifies type **52** as offload traffic for +WHOOP 5/MG. Both exclude live `REALTIME_DATA` (40) and `REALTIME_RAW_DATA` (43) +so those streams cannot keep the backfill idle watchdog active. The parser also exposes irregular fields through per-type **post-hooks** (`registerPostHooks()` in `PostHooks.swift`): `realtime_data`, `event`, `command_response`, @@ -186,9 +369,13 @@ The legacy WHOOP 4 `BATTERY_LEVEL` event decoder uses this layout (see the `even ## CommandNumber (sending) — client subset -**Historical NOOP sender inventory.** The table below records client payload conventions, primarily WHOOP 4. It is not the WHOOP 5/MG command contract or a recommendation to send every listed operation. Use the [command reference](PROTOCOL_COMMANDS.md) for current meanings and the [alarm reference](PROTOCOL_ALARMS.md) for revisioned alarms. +**Non-exhaustive historical NOOP sender selection.** The table below records client +payload conventions, primarily WHOOP 4. It is not a complete inventory, the WHOOP +5/MG command contract or a recommendation to send every listed operation. Use the +[command reference](PROTOCOL_COMMANDS.md) for current meanings and the [alarm reference](PROTOCOL_ALARMS.md) +for revisioned alarms. -NOOP exposes a curated, **safe** command set in `WhoopCommand` (`Strand/BLE/Commands.swift`). +NOOP exposes a curated, **safe** command set in `WhoopCommand` (`../Strand/BLE/Commands.swift`). The raw value is the on-wire command byte at `[6]` (inside a type-35 `COMMAND` frame). Commands are built by `WhoopCommand.frame(seq:payload:)` and written to `…0002`. @@ -203,54 +390,76 @@ public func frame(seq: UInt8, payload: [UInt8] = [0x00]) -> [UInt8] { | Code | Command | Typical payload | Purpose | |-----:|---------|-----------------|---------| -| 1 | `LINK_VALID` | — | link keep-alive | -| 3 | `TOGGLE_REALTIME_HR` | `[0x01]`/`[0x00]` | start/stop live HR stream (type-40) | -| 7 | `REPORT_VERSION_INFO` | — | firmware versions (decoded by `command_response` hook) | -| 10 | `SET_CLOCK` | `[secs u32 LE][subsecs u32 LE]` | set strap RTC (UTC) | -| 11 | `GET_CLOCK` | *empty* | read RTC → `ClockRef` correlation | +| 3 | `TOGGLE_REALTIME_HR` | `[0x01]`/`[0x00]` | sent by NOOP outside the documented 41.17.6.0 command set; standard BLE HR remains separate | +| 7 | `REPORT_VERSION_INFO` | `[0x00]` | firmware versions (decoded by `command_response` hook) | +| 10 | `SET_CLOCK` | `[secs u32 LE][subsecs u32 LE]` | request form observed in use outside the documented 41.17.6.0 command set; one of two forms can latch on some devices | +| 11 | `GET_CLOCK` | *empty* or `[0x00]` | request forms observed in use outside the documented 41.17.6.0 command set; readback selects the effective form | | 22 | `SEND_HISTORICAL_DATA` | `[0x00]` | begin offload of the type-47 store | | 23 | `HISTORICAL_DATA_RESULT` | `[0x01] + end_data(8)` | ack a `HISTORY_END` chunk / advance trim | | 26 | `GET_BATTERY_LEVEL` | `[0x00]` | battery percent; also the **bond** write | -| 34 | `GET_DATA_RANGE` | `[0x00]` | strap's stored oldest/newest record range; #689 also logs a diagnostic ring-buffer page backlog — see below | -| 35 | `GET_HELLO_HARVARD` | `[0x00]` | identity/version hello; the response carries the 4.0 strap serial — see below | -| 39 / 40 | `SET_LED_DRIVE` / `GET_LED_DRIVE` | — | optical LED drive (research) | -| 41 / 42 | `SET_TIA_GAIN` / `GET_TIA_GAIN` | — | optical front-end gain (research) | -| 43 / 44 | `SET_BIAS_OFFSET` / `GET_BIAS_OFFSET` | — | optical bias (research) | +| 34 | `GET_DATA_RANGE` | `[0x00]` | strap's stored oldest/newest record range; #689 also logs a [diagnostic ring-buffer page backlog](#get_data_range-ring-backlog-689-diagnostic-only) | +| 35 | `GET_HELLO_HARVARD` | `[0x00]` | identity/version hello; the response carries the [WHOOP 4 serial](PROTOCOL_WHOOP4.md#get_hello_harvard-35-response--the-whoop-40-serial) | | 63 | `SEND_R10_R11_REALTIME` | `[0x00]` off / `[0x01]` on | the **real** type-43 raw-stream switch | -| 66 | `SET_ALARM_TIME` | `[0x01]+epoch u32 LE+[0,0]` | arm firmware alarm | +| 66 | `SET_ALARM_TIME` | `[0x01]+epoch u32 LE+subseconds u16 LE+[0,0]` (9-byte NOOP request) | seven semantic bytes; final two zero bytes are not evaluated | | 67 | `GET_ALARM_TIME` | `[0x01]` | read armed alarm | | 68 | `RUN_ALARM` | `[0x01]` | app-driven alarm now | | 69 | `DISABLE_ALARM` | `[0x01]` | disarm firmware alarm | | 76 | `GET_ADVERTISING_NAME_HARVARD` | `[0x00]` | advertised name | +| 77 | `SET_ADVERTISING_NAME_HARVARD` | two reserved bytes + client name + NUL | NOOP allows up to 24 client bytes; the documented device field retains at most 15 and forces its last byte to NUL | | 79 | `RUN_HAPTICS_PATTERN` | `[patternId, loops, 0,0,0]` | buzz a preset haptic pattern | -| 80 | `GET_ALL_HAPTICS_PATTERN` | — | enumerate preset patterns | | 81 / 82 | `START_RAW_DATA` / `STOP_RAW_DATA` | `[0x01]` | raw-data collection toggle | -| 84 | `GET_BODY_LOCATION_AND_STATUS` | — | wrist/body-location status (read-only diagnostic probe, #690 — below) | -| 96 / 97 | `ENTER_HIGH_FREQ_SYNC` / `EXIT_HIGH_FREQ_SYNC` | `[0x00]` | high-freq offload mode | -| 98 | `GET_EXTENDED_BATTERY_INFO` | — | extended battery (mV etc.) | -| 100 | `CALIBRATE_CAPSENSE` | — | recalibrate cap-touch | -| 105 / 106 | `TOGGLE_IMU_MODE_HISTORICAL` / `TOGGLE_IMU_MODE` | `[0x01]` | IMU stream mode | -| 107 | `ENABLE_OPTICAL_DATA` | — | optical (PPG) data | -| 117 | `START_FF_KEY_EXCHANGE` | `[0x01]` | how many feature flags the firmware knows (read-only enumeration probe, #761 — below) | +| 84 | `GET_BODY_LOCATION_AND_STATUS` | `[0x00]` | wrist/body-location status (read-only diagnostic probe, #690 — below) | +| 96 / 97 | `ENTER_HIGH_FREQ_SYNC` / `EXIT_HIGH_FREQ_SYNC` | retained client `[0x00]` forms | NOOP uses 97 during watchdog recovery; these client forms do not replace the documented WHOOP 4 contracts | +| 98 | `GET_EXTENDED_BATTERY_INFO` | `[0x00]` | extended battery (mV etc.) | +| 106 | `TOGGLE_IMU_MODE` | `[0x01]` | older one-byte NOOP form; the 41.17.6.0 SET contract is `[01, state]` | +| 107 | `GET_IMU_DATA_STREAM` | `[0x01]` | reads stored IMU stream state on 41.17.6.0; the WHOOP 5/MG identifier `ENABLE_OPTICAL_DATA` does not describe this WHOOP 4 operation | +| 117 | `START_FF_KEY_EXCHANGE` | `[0x01]` | the enumerated feature-name count (read-only enumeration probe, #761 — below) | | 118 | `SEND_NEXT_FF` | `[0x01]` | next feature-flag NAME (cursor, not index; read-only, #761 — below) | | 122 | `STOP_HAPTICS` | `[0x00]` | stop an in-progress haptic | -| 123 | `SELECT_WRIST` | — | set strap wrist | - -**5/MG raw-IMU sequence (hardware-verified):** command 106 accepting a write does not mean that the +| 123 | `SELECT_WRIST` | `[0x01, arg]` | MG-only active in-memory wrist selection; persistence across reboot is not established; `arg` is the selected wrist value | + +**Decode-only or historical inventory entries.** The following names are present +in protocol metadata or older notes but have no case in Swift `WhoopCommand`, so +they are not part of the sending subset above: `LINK_VALID` (1), +`SET_LED_DRIVE`/`GET_LED_DRIVE` (39/40), `SET_TIA_GAIN`/`GET_TIA_GAIN` (41/42), +`SET_BIAS_OFFSET`/`GET_BIAS_OFFSET` (43/44), `GET_ALL_HAPTICS_PATTERN` (80), +`CALIBRATE_CAPSENSE` (100), and `TOGGLE_IMU_MODE_HISTORICAL` (105). Command 105 +is outside the documented WHOOP 4 41.17.6.0 command set and has no recorded +observation there. + +Command 123 is formed only through the MG ECG path. `Whoop5Ecg.commandPayload(arg:)` +supplies `[0x01, arg]`, and `BLEManager.send(_:)` rejects every ECG-family command, +including `SELECT_WRIST`, unless the selected family is WHOOP 5/MG. It is never +sent to WHOOP 4. + +**WHOOP 5/MG raw-IMU sequence (hardware-verified):** command 106 accepting a write does not mean that the producer started. A bounded capture first sends `START_RAW_DATA` (81) `[0x01]`, then command 106 with the two-byte selector `[0x01, 0x01]`. Stop uses `STOP_RAW_DATA` (82) `[0x01]`, then command 106 -`[0x01, 0x00]`. The one-byte payload in the table remains the WHOOP 4 form. See -[5/MG raw data capture](RAW_DATA_CAPTURE.md) for storage, history repair, and export semantics. - -**Payload builders** in `WhoopCommand`: - -- `setAlarmPayload(epochSec:)` → `[0x01] + epoch u32 LE + [0x00, 0x00]` (7 bytes). -- `BLEManager.setClockPayload(now:)` → `[secs u32 LE][0,0,0,0]` (8 bytes; subseconds in - 1/32768 s, zero is fine). - -> **Note on `ENTER_HIGH_FREQ_SYNC` (96):** current builds do **not** enter high-freq sync; they -> send `EXIT_HIGH_FREQ_SYNC` (97) defensively on connect to release a strap a previous app may -> have parked there. Plain `SEND_HISTORICAL_DATA` returns the type-47 store without it. +`[0x01, 0x00]`. The one-byte payload in the table is older NOOP behavior, not the +WHOOP 4 41.17.6.0 request contract. See +[WHOOP 5/MG raw data capture](RAW_DATA_CAPTURE.md) for storage, history repair, and export semantics. + +**Payload construction** in `WhoopCommand`: + +- `setAlarmPayload(epochSec:)` → `[0x01] + epoch u32 LE + subseconds u16 LE + [0x00, 0x00]` + (9-byte request). The first seven bytes are the semantic body; the final two zero + bytes are not evaluated. A seven-byte request was acknowledged in one run but did + not produce the scheduled vibration; the subsecond field, not body length, is semantic. +- `BLEManager.setClockPayload(now:)` → `[secs u32 LE][0,0,0,0]` (8 bytes). This is + a request form observed in use outside the documented 41.17.6.0 command set. +- `BLEManager.setClockPayloadLegacy(now:)` → `[secs u32 LE][0,0,0,0,0]` (9 bytes; + another request form observed in use). On some devices one of these SET_CLOCK + forms was observed to latch; read back to confirm. + +**WHOOP 5/MG battery decoder boundaries:** command 26 returns a four-byte `u32le` +whole-percent value, including four zero bytes on the documented error path. NOOP +currently reads only the lowest byte. For command 151, NOOP divides the raw `u16le` +charge field by 10 for display; that scale is a client convention, not a confirmed +property of the wire value. + +> **Note on `ENTER_HIGH_FREQ_SYNC` (96):** current builds do **not** enter high-freq sync. NOOP +> sends `EXIT_HIGH_FREQ_SYNC` (97) during watchdog recovery. Plain `SEND_HISTORICAL_DATA` +> returns the type-47 store without it. ## Additional 5-class command numbers @@ -264,12 +473,12 @@ send these; they are recorded for completeness. | 62 (0x3E) | `GET_AFE_PARAMETERS` | read optical AFE parameters | On MAVERICK the clock commands also answer in the high opcode space — `SET_CLOCK` at 146 (0x92) -and `GET_CLOCK` at 147 (0x93), alongside `GET_HELLO` at 145 (0x91) — distinct from the 4.0 +and `GET_CLOCK` at 147 (0x93), alongside `GET_HELLO` at 145 (0x91) — distinct from the WHOOP 4 numbers (10 / 11) above. The ECG family is resolved as wrist selection (123), processing start/stop (124), raw saving (125), raw live delivery (126), filtered saving (127), and filtered live -delivery (139). Noncontiguous IDs are not evidence of a mistaken mapping. Requests and +delivery (139). Noncontiguous IDs do not imply a mistaken mapping. Requests and packet contracts are in [ECG](PROTOCOL_ECG.md); all remaining IDs are covered by the [complete command reference](PROTOCOL_COMMANDS.md). @@ -290,11 +499,11 @@ establish a feature gate or contradict the later version-bound mapping. See `CommandCatalogueTest`. NOOP sends these only from the gated, hand-run MG ECG probe described in -[ECG controls](PROTOCOL.md#91-ecg-labrador-on-the-mg) — never automatically, never on a plain 5.0 or a 4.0, and only +[ECG controls](PROTOCOL_ECG.md#commands-and-independent-output-gates) — never automatically, never on a plain WHOOP 5 or WHOOP 4, and only behind the Experimental opt-in plus a positively-identified MG. Existing probe implementation and older observations must be distinguished from the expanded contract. -live IMU control is 106 and BLE UART control is 103; they are distinct +On the wire, live IMU control is 106 and BLE UART control is 103; they are distinct operations. See [collection controls](PROTOCOL_CONFIGURATION.md#collection-storage-and-live-transport). The configuration probing notes below describe earlier client behavior and unanswered @@ -305,13 +514,15 @@ current absence-of-support claims. ## Destructive commands — *do not send* -These exist on the wire but are **deliberately excluded** from `WhoopCommand`. They can wipe -data, brick, or power-cycle the strap. NOOP must never send them. +These exist on the wire but are **deliberately excluded** from ordinary +`WhoopCommand` use. They can wipe data, brick, or power-cycle the strap. NOOP must +never send them, except command 32 through the narrowly scoped, user-confirmed +WHOOP 4 probe described below. | Code | Command | Hazard | |-----:|---------|--------| | 25 | `FORCE_TRIM` | invasive history cursor/reclamation operation; unoffloaded data may become unavailable | -| 32 | `POWER_CYCLE_STRAP` | power-cycles (gated probe exception — see below) | +| 32 | `POWER_CYCLE_STRAP` | power-cycles ([gated probe exception](#whoop-4-reboot-probe-235)) | | 36 | `START_FIRMWARE_LOAD` | firmware write | | 37 | `LOAD_FIRMWARE_DATA` | firmware write | | 38 | `PROCESS_FIRMWARE_IMAGE` | firmware write | @@ -332,52 +543,61 @@ both platforms; it was missing from this table, so nothing recorded that it must `SET_ADVERTISING_NAME_HARVARD` (rename applies on reboot). In `WhoopCommand` as `rebootStrap`, sent only from the user-initiated, confirmation-gated "Restart strap" action (`BLEManager.rebootStrap()` / `WhoopBleClient.rebootStrap()`) (#166). -- **`POWER_CYCLE_STRAP` (32)** — a harder restart, in the enum as `powerCycleStrap` **only** as a candidate - for the WHOOP 4.0 reboot probe (below). Sent only from `rebootProbe(.powerCycle32Empty)`, itself gated - behind Test Centre → Connection + a confirmation, and 4.0-only. Never on a default install. +- **`POWER_CYCLE_STRAP` (32)** — a harder restart, in the enum as `powerCycleStrap` **only** for the + WHOOP 4 reboot probe variants `powerCycle32Empty` and `powerCycle32Payload1` (below). Each is gated + behind Test Centre → Connection + a confirmation, and WHOOP-4-only. Never on a default install. Everything else in this table stays out of the enum entirely. -## WHOOP 4.0 reboot probe (#235) + - A real 4.0 silently ignores the production `REBOOT_STRAP` frame (see -below) and the correct 4.0 reboot frame is unknown. The probe (Test Centre → Connection, 4.0 only) sends -one candidate at a time — `REBOOT_STRAP(29)` empty, `POWER_CYCLE_STRAP(32)` empty, or -`REBOOT_STRAP(29)` with `[0x01]` — reusing the reboot watchdog so the strap log shows which one drops the -link (worked) vs is ignored. The definitive fix is still an HCI capture of the official app rebooting a -4.0 (the way the alarm frame was pinned, #535). Driven by `BLEManager.rebootProbe(_:)` / -`WhoopBleClient.rebootProbe(...)`; candidates enumerated in `RebootProbeVariant`. +## WHOOP 4 reboot probe (#235) + +The documented 41.17.6.0 contract does not evaluate the body for either restart +command: empty, `00` and `01` are equivalent, and an accepted request returns result 1. +The NOOP probe (Test Centre → Connection, WHOOP 4 only) sends one candidate at a time: +`REBOOT_STRAP(29)` empty, `POWER_CYCLE_STRAP(32)` empty, +`REBOOT_STRAP(29)` with `[0x01]`, `POWER_CYCLE_STRAP(32)` with `[0x01]`, or +`REBOOT_STRAP(29)` with `[0x00]`. It reuses the reboot watchdog so the strap log shows +which candidate drops the link versus being ignored. One device observation did not +show a response, disconnect or reboot for command 29, so that physical effect is not yet confirmed on hardware. +`BLEManager.rebootProbe(_:)` / `WhoopBleClient.rebootProbe(...)` enumerate all five through +`RebootProbeVariant`. ## Body-location probe (#690) This paragraph records the older client decoder; the [current response body](PROTOCOL_COMMANDS.md#ordinary-service-commands) is documented separately. - A read-only, user-triggered diagnostic (Test Centre → Connection, both +A read-only, user-triggered diagnostic (Test Centre → Connection, both families) that sends `GET_BODY_LOCATION_AND_STATUS` (84 / `0x54`) and dumps the strap's full raw COMMAND_RESPONSE to the strap log + a copyable dialog. The 4-byte inner-payload record is `revision · location · confidence · status`; `location` maps `0 UNKNOWN, 1 WRIST, 2 BICEP, 3 CALF, 4 SIDE_TORSO, 5 GLUTE, 7 ANKLE, 128 NOT_CONCLUSIVE, 160 UNKNOWN_GARMENT` (any other value — including the gap at 6 — is kept raw; `confidence`/`status` stay raw until captures establish their semantics). Decoded -only on WHOOP 4.0, where the inner payload starts at the command byte + 1; on 5/MG the puffin envelope's -result code sits where `location` would land, so the raw grid is shown and the record is left undecoded +only on WHOOP 4. On 5/MG the command-response body starts at the command byte + 3, +after command, origin sequence and result; the raw grid is shown and the record is left undecoded until a real 5/MG capture maps the offset. **Never** feeds wear detection, sleep gating, or scoring. Driven by `BLEManager.probeBodyLocationAndStatus()` / `WhoopBleClient.probeBodyLocationAndStatus()`; -formatted by the pure `BodyLocationProbe` twin (Swift↔Kotlin byte-parity locked by a golden test). The -layout + enum facts are reverse-engineered from the WHOOP app and reimplemented in NOOP's own code -(facts, not copied expression — see [`ATTRIBUTION.md`](../ATTRIBUTION.md)). +formatted by the pure `BodyLocationProbe` twin (Swift↔Kotlin byte-parity locked by a golden test). +The layout is implemented independently in NOOP; unknown enum values remain raw. ## Feature-flag enumeration probe (#761, read-only) The probe’s older count model differs from the current u8 field; use the [named configuration interface](PROTOCOL_CONFIGURATION.md#named-configuration-interface). - NOOP has always been able to WRITE a feature flag -(`SET_FF_VALUE` / 120, the R22 unlock in `Whoop5Config`) but never to ASK a strap which flags it knows. -The `CommandNumber` table names a full symmetric read side that was never implemented — 117 -`START_FF_KEY_EXCHANGE` / 118 `SEND_NEXT_FF` for feature flags, 115 / 116 for device config — and this -probe uses the enumerate pair only: **names, no values, nothing written.** `GET_FF_VALUE` (128) is -deliberately not sent: the only hands-on report of it (`johnmiddleton12/wearable`, run on the author's -own WHOOP 4.0 on the earlier WHOOP 4 baseline) states its reply's value field is contaminated by a stale shared buffer, -so an on/off read is unreliable; the same session ran the 117→118 loop and got a complete key dump. +NOOP has always been able to WRITE a feature flag +(`SET_FF_VALUE` / 120, the R22 unlock in `Whoop5Config`). The feature-name walk uses +117 `START_FF_KEY_EXCHANGE` followed by repeated 118 `SEND_NEXT_FF`: **names, no +values, nothing written.** `GET_FF_VALUE` (128) is +deliberately not sent by this enumeration path: the only hands-on report of it +(`johnmiddleton12/wearable`, run on the author's own WHOOP 4 on the earlier WHOOP 4 baseline) +states its reply's value field is contaminated by a stale shared buffer, so an on/off read is unreliable; +the same session ran the 117→118 loop and got a complete key dump. + +That scope is not a global send prohibition. The separate device-config value probe uses +`GET_FF_VALUE` for named value reads, and the R22-disable sequence requires it as the read-back after +each `SET_FF_VALUE`. `BLEManager.send(_:)` admits that latter path only while an R22 disable run exists, +through `FeatureFlagWriteGate.isReadBackOpcode(_:)`. Requests and reply fields are specified in the [named configuration interface](PROTOCOL_CONFIGURATION.md#named-configuration-interface). The older probe’s count decoder is not the current byte contract. @@ -392,7 +612,7 @@ and its 115/116 walk ended on a single reply carrying `index = 255` **and** `val `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 +a repeated index during such a run (a parked cursor), 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 @@ -400,11 +620,11 @@ reply are logged beside the fields decoded from them. Driven by `BLEManager.prob `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 -frames). Result goes to a copyable dialog + the strap log; no storage. The field order and opcode numbers -are facts read off a decompiled official client's response types and corroborated by that 4.0 dump, -reimplemented in NOOP's own code — facts, not copied expression (see [`ATTRIBUTION.md`](../ATTRIBUTION.md)). -**Historical probe scope:** the published comparison dump is a 4.0's R19-era list. The -the reference baseline enumeration commands and eligible-key inventory are now described in +frames). Result goes to a copyable dialog + the strap log; no storage. The field +order and opcode numbers are implemented in NOOP and agree with that WHOOP 4 +observation; unknown fields remain raw. +**Historical probe scope:** the published comparison dump is a WHOOP 4 R19-era list. +The reference-baseline enumeration commands and eligible-key inventory are now described in [configuration](PROTOCOL_CONFIGURATION.md); this does not validate every older reply layout. ## Device-config read probe (#103, read-only) @@ -423,37 +643,35 @@ measurement establish an entitlement or subscription gate. Beyond the oldest/newest timestamps NOOP already scans from a `GET_DATA_RANGE` reply, the app computes a ring-buffer page backlog from three u32s in the -command-response inner payload (whose byte 0 is a subtype): write page `W = V(2)`, acknowledged/trim boundary `D = V(3)`, -ring capacity `T = V(5)`, where `V(i)` is the u32 at inner offset `i·4 + 1` (frame offsets `cmdOff + 10/14/22` +65-byte body (`01` followed by 16 `u32le` values): write page `W = V(2)`, acknowledged/trim boundary `D = V(3)`, +ring capacity `T = V(5)`, where `V(i)` is the u32 at inner offset `i·4 + 3` (frame offsets `cmdOff + 12/16/24` here). In the current WHOOP 5/MG [range layout](PROTOCOL_TRANSPORT.md#data-range--command-34), the read-page cursor is `V(1)`; `V(3)` measures the acknowledged boundary instead. Backlog with wraparound: `W < D ? W + (T − D) : W − D`. `DataRange.pagesBehind` (Swift + Kotlin twins, byte-parity, unit-tested for normal / wraparound / too-short / implausible) logs `Strap backlog pages behind: N` when it decodes plausibly — read u32 LE, guarded on frame length + a capacity sanity ceiling. **Never** -gates sync or backfill: the layout is RE'd from the WHOOP app (facts, reimplemented in NOOP's own code, see -[`ATTRIBUTION.md`](../ATTRIBUTION.md)) but **not yet confirmed against real 4.0 / 5-MG captures**, so it stays -a log-only diagnostic until a fixture pins the offsets + endianness. - -**Payload forms** (decoded from the official app's command builders — recorded so the wire format is -*known*: for the destructive commands, known-and-avoidable; for the one guarded exception, -`REBOOT_STRAP`, known-and-used by `rebootStrap()`). The opcodes are shared across WHOOP 4 (harvard) -and WHOOP 5/MG (puffin): the app's unified command enum (`EnumC58479e`) uses the same `25`/`29`/`32` -on both transports — unlike haptics, which has a maverick-specific `0x13`. - -- `FORCE_TRIM` (25) — body is **two little-endian int32 range args**. One app-built - form sets both to `-16843010` (`0xFEFEFEFE`, builder `rh0.C45484g`: - `new C45484g(-16843010, -16843010)`). It is **not** an empty/`[0x00]` payload. +gates sync or backfill: the layout is not yet confirmed against real WHOOP 4 or WHOOP 5/MG +device observations, so it stays a log-only diagnostic. + +**Payload forms** are recorded so destructive commands can be avoided and the +guarded reboot operation can be encoded correctly. Commands 25, 29 and 32 use the +same numeric IDs on WHOOP 4 and WHOOP 5/MG; haptics differ by generation. + +- `FORCE_TRIM` (25) — body is **two little-endian int32 range arguments**. The + documented special form sets both to `-16843010` (`0xFEFEFEFE`). It is **not** + an empty/`[0x00]` payload. In the WHOOP 5/MG profile, this pair enters the same history-storage event path as the chunk acknowledgement: it selects a special mode and the current write boundary. This is an invasive cursor/reclamation operation; it does not establish physical erasure of the entire flash history or guarantee that every stored record becomes unavailable. See [special history acknowledgement tokens](PROTOCOL_TRANSPORT.md#history-sequencing-and-storage-ownership). -- `REBOOT_STRAP` (29) — **empty body** (builder `rh0.C45476d0` passes a null payload). The strap drops +- `REBOOT_STRAP` (29) — **empty body** on the WHOOP 5/MG path. The strap drops the BLE link and re-advertises after boot; stored data is kept. Non-destructive, but interrupts any in-flight offload. **WHOOP 5.0 (puffin): hardware-confirmed** — the empty-body frame reboots a 5.0 - (the earlier reboot observation, #227). **WHOOP 4.0 (harvard): NOT confirmed** — a real 4.0 silently ignores this - empty-body frame (#235: no reboot, no disconnect, no COMMAND_RESPONSE), so the correct 4.0 form (a - payload byte? a different opcode?) still needs an HCI capture of the official app rebooting a 4.0. + (the earlier reboot observation, #227). On WHOOP 4 41.17.6.0, commands 29 and 32 + document distinct restart actions, ignore the request body and return result 1. + A separate device observation showed no response, disconnect or reboot for 29, + so the physical action is not yet confirmed on hardware. --- @@ -472,7 +690,7 @@ HISTORY_START ─▶ open chunk, accumulate type-47 records ├─ HISTORY_END(unix, trim) ──▶ finishChunk: │ 1. decode chunk (extractHistoricalStreams, using ClockRef) │ 2. await store.insert(decoded) ── decoded durable - │ 3. [if raw enabled] await enqueueRawBatch ── raw durable + │ 3. [Swift, if raw enabled] await enqueueRawBatch ── raw durable │ 4. await setCursor("strap_trim", trim) ── cursor durable │ 5. ackTrim → HISTORICAL_DATA_RESULT([0x01]+end_data, .withResponse) │ (chunk cleared; chunkOpen stays TRUE — high-freq sends repeated ENDs) @@ -490,14 +708,18 @@ following records form the next chunk. An `END` with no accumulated records is * ## Safe-trim invariant -NOOP sends the normal chunk acknowledgement only after local durability. This is a client persistence invariant; it does not prove all device read/erase behavior or exactly-once delivery. From -`Backfiller.finishChunk(...)`: +NOOP sends the normal chunk acknowledgement only after local durability. This is a client persistence invariant; it does not prove all device read/erase behavior or exactly-once delivery. The Swift path in +`Backfiller.finishChunk(...)` is: ``` -decode → await insert(decoded) → [await enqueueRawBatch] → await setCursor("strap_trim") → ackTrim +decode → await insert(decoded) → [Swift: await enqueueRawBatch] → await setCursor("strap_trim") → ackTrim ``` -Any thrown error in that sequence short-circuits before the client sends the ack. The +Android commits decoded rows and its cursor before acknowledgement, while an +enabled capture file is buffered separately rather than inserted into this +per-chunk sequence. Android's undecodable-record archive remains a separate +pre-ack durability condition. Any error in the applicable sequence short-circuits +before the client sends the ack. The ack itself is the link-layer half: `HISTORICAL_DATA_RESULT(23)` with payload `[0x01] + end_data` written `.withResponse`. A BLE write confirmation is not itself proof of physical erasure or power-loss durability. The `strap_trim` cursor is persisted, so the client retains progress for another attempt; exact device replay after disconnect is not guaranteed. This local progress does not depend on a network. @@ -506,11 +728,11 @@ written `.withResponse`. A BLE write confirmation is not itself proof of physica ## Watchdog & liveness -- **Idle watchdog** (`backfillIdleTimeoutSeconds = 60`): re-armed on every genuine offload frame - (47/48/49/50) and only those; if the strap goes silent the session exits and resumes next time - via the durable cursor. The live type-43 flood is dropped during offload so it cannot starve - chunk acks. -- **Stuck detector** (`StuckStrapDetector`): after an offload, if the strap reports records newer +- **Idle watchdog** (`backfillIdleTimeoutSeconds = 60`): Swift re-arms it for offload + types 47/48/49/50/56. Android uses the same list and, for WHOOP 5/MG, also type + 52. Both exclude the live type-43 flood. If the strap goes silent, the session + exits and resumes next time via the durable cursor. +- **Swift-specific stuck detector** (`StuckStrapDetector`): after an offload, if the strap reports records newer than NOOP's frontier (from `GET_DATA_RANGE`, parsed by `dataRangeNewestUnix(from:)`) **and** that frontier has been frozen for the detector window, it flags `strapNeedsReboot` and attempts a defensive recovery (`EXIT_HIGH_FREQ_SYNC` + `SET_CLOCK`). Off-wrist / caught-up (strap not @@ -587,7 +809,9 @@ inherit a base layout and override only what changed. The streamed decode that f -## SpO₂ on 5.0 / MG — what the wire does and does not carry + + +## SpO₂ on WHOOP 5/MG — what the wire does and does not carry No dedicated SpO₂ read operation is identified in the current command reference. R18 byte 82 has no established physiological meaning. NOOP imports @@ -601,26 +825,28 @@ vendor's aggregation or calibration algorithm. See the | Path | Responsibility | |------|----------------| -| `Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift` | SOF/length/CRC8/CRC16/CRC32, `verifyFrame`, `Reassembler`, `frameFromPayload` | -| `Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift` | `parseFrame` (4.0 + 5.0), `ParsedFrame`, field builder | -| `Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceFamily.swift` | UUID strings, header-CRC kind, `CLIENT_HELLO`, puffin aliasing | -| `Packages/WhoopProtocol/Sources/WhoopProtocol/Schema.swift` | JSON schema model + `loadSchema()` | -| `Packages/WhoopProtocol/Sources/WhoopProtocol/PostHooks.swift` | per-type irregular-field decoders | -| `Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift` | `classifyHistoricalMeta` (START/END/COMPLETE) | -| `Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json` | canonical enums + packet layouts | -| `Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift` | MG ECG ("Labrador") packet decode + command construction | -| `Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5EcgProbe.swift` | ECG turn-on report + the run-scoped result-code verdicts | -| `Strand/BLE/BLEManager.swift` | CoreBluetooth transport, bond, connect lifecycle, backfill orchestration | -| `Strand/BLE/Commands.swift` | safe `WhoopCommand` set + outbound frame builder | -| `Strand/BLE/FrameRouter.swift` | decode → `LiveState` (UI) | -| `Strand/BLE/StandardHeartRate.swift` | `0x2A37` HR/R-R parser | -| `Strand/Collect/Backfiller.swift` | historical-offload state machine + safe-trim invariant | +| `../Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift` | SOF/length/CRC8/CRC16/CRC32, `verifyFrame`, `Reassembler`, `frameFromPayload` | +| `../Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift` | `parseFrame` (WHOOP 4 and WHOOP 5/MG), `ParsedFrame`, field construction | +| `../Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceFamily.swift` | UUID strings, header-CRC kind, `CLIENT_HELLO`, puffin aliasing | +| `../Packages/WhoopProtocol/Sources/WhoopProtocol/Schema.swift` | JSON schema model + `loadSchema()` | +| `../Packages/WhoopProtocol/Sources/WhoopProtocol/PostHooks.swift` | per-type irregular-field decoders | +| `../Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift` | `classifyHistoricalMeta` (START/END/COMPLETE) | +| `../Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json` | canonical enums + packet layouts | +| `../Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5Ecg.swift` | MG ECG ("Labrador") packet decode + command construction | +| `../Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5EcgProbe.swift` | ECG turn-on report + the run-scoped result-code verdicts | +| `../Strand/BLE/BLEManager.swift` | CoreBluetooth transport, bond, connect lifecycle, backfill orchestration | +| `../Strand/BLE/Commands.swift` | safe `WhoopCommand` set + outbound frame construction | +| `../Strand/BLE/FrameRouter.swift` | decode → `LiveState` (UI) | +| `../Strand/BLE/StandardHeartRate.swift` | `0x2A37` HR/R-R parser | +| `../Strand/Collect/Backfiller.swift` | historical-offload state machine + safe-trim invariant | +| `../android/app/src/main/java/com/noop/protocol/Enums.kt` | Android command and event identifiers | +| `../android/app/src/main/java/com/noop/protocol/Framing.kt` | Android family-aware framing and response decoding | +| `../android/app/src/main/java/com/noop/ble/WhoopBleClient.kt` | Android BLE lifecycle, command sending and offload routing | --- -*Reverse-engineering credit: `johnmiddleton12/my-whoop` (WHOOP 4.0) and `b-nnett/goose` -(WHOOP 5.0). This is an independent interoperability project for the user's own device and data; -it is not affiliated with WHOOP and is not a medical device.* +*This is an independent interoperability project for the user's own device and +data; it is not affiliated with WHOOP and is not a medical device.* ## Earlier command-response observations diff --git a/docs/PROTOCOL_SENSORS.md b/docs/PROTOCOL_SENSORS.md index a5a87615aa..bfaa9361d1 100644 --- a/docs/PROTOCOL_SENSORS.md +++ b/docs/PROTOCOL_SENSORS.md @@ -1,4 +1,6 @@ -# WHOOP 5/MG sensor records +# WHOOP sensor records + + Applicability: [central scope and compatibility](PROTOCOL.md#scope-and-compatibility). @@ -9,11 +11,176 @@ records and their validity boundaries. It complements the historical For ECG R16/R17, use [the ECG contract](PROTOCOL_ECG.md). Frame shapes, count capacities and processing qualifications follow the central -scope. Earlier NOOP physical scales and timing conventions remain explicitly +scope. Earlier physical scales and timing conventions remain explicitly qualified; they do not establish a new hardware calibration or every device’s sensor configuration. -## Packet types, record layouts and integrity +## Contents + +- [WHOOP 4](#whoop-4) + - [WHOOP 4 realtime heart-rate record (type 40)](#whoop-4-realtime-heart-rate-record-type-40) + - [WHOOP 4 realtime raw layouts (type 43)](#whoop-4-realtime-raw-layouts-type-43) + - [WHOOP 4 historical v24](#whoop-4-historical-v24) + - [WHOOP 4 historical v25 and unknown versions](#whoop-4-historical-v25-and-unknown-versions) + - [WHOOP 4 sensor and record controls](#whoop-4-sensor-and-record-controls) +- [WHOOP 5/MG](#whoop-5mg) + - [Packet types, record layouts and integrity](#packet-types-record-layouts-and-integrity) + - [Packet 40: live HR and R-R](#packet-40-live-hr-and-r-r) + - [R18: biometric summary](#r18-biometric-summary) + - [Step source, cadence and activity](#step-source-cadence-and-activity) + - [Motion/rest state and override](#motionrest-state-and-override) + - [R18 quality-adjacent source-selection bits](#r18-quality-adjacent-source-selection-bits) + - [R20: optical blocks](#r20-optical-blocks) + - [Configuration and conditional routing](#configuration-and-conditional-routing) + - [R21: six-axis IMU](#r21-six-axis-imu) + - [Inertial record timestamps](#inertial-record-timestamps) + - [R22 inner version](#r22-inner-version) + - [R22 version 9 queued channels and sample encoding](#r22-version-9-queued-channels-and-sample-encoding) + - [R22 version 9 metadata refinement](#r22-version-9-metadata-refinement) + - [R26: compact optical window](#r26-compact-optical-window) + - [Dedicated IMU stream types 51 and 52](#dedicated-imu-stream-types-51-and-52) + - [Implementation boundaries](#implementation-boundaries) + - [Constructed arithmetic checks](#constructed-arithmetic-checks) + + + +## WHOOP 4 + +WHOOP 4 uses its own envelope and versioned record layouts. These boundaries are +capture-backed or retained interoperability conventions; they are not derived from +the WHOOP 5/MG tables below. + +| Record | Established WHOOP 4 boundary | +|---|---| +| Type 40 realtime | The WHOOP 4 type-40 layout and the standard Heart Rate Service are separate sources. Do not apply the WHOOP 5/MG packet-40 absolute offsets below. | +| Type 43 realtime raw | Several legacy layouts exist. Command 63 controls the observed R10/R11 stream; command 82 does not stop it. Preserve an unknown layout instead of guessing from type alone. | +| Type 47 v24 | Schema-backed legacy biometric/optical/IMU record. On one 41.17.6.0 offload all 1,704 records were CRC-valid v24; HR agreed with `60000 / mean(R-R)` to about 1 bpm and gravity magnitude was about 1 g. This is one capture, not a generation default. | +| Type 47 v25 | Version 41.17.6.0 also produces this 84-byte frame depending on device configuration: layout byte 25 at frame 5, Unix seconds `u32le` at 11, sensor block from 23 and three signed movement values at 73/75/77. Interpreting those values as gravity scaled by `1/16384` is observation-based, not a confirmed device parameter. No per-second HR field is mapped. | +| Legacy v5/7/9/12 | Retained layout variants. Their documentation is not proof that every field was independently captured on current firmware. | +| Legacy IMU block | Payload length 1,917 means declared length 1,924. The layout has 100 signed `i16le` samples per axis at the offsets listed in the [WHOOP 4 profile](PROTOCOL_WHOOP4.md#legacy-imu-layout). Scales `1/4096` and `2000/32768` are commonly applied interpretations, not confirmed device parameters. | + +For historical records, select the layout from the emitted version and validate +the complete family-specific frame length and CRCs first. Version 24 and 25 are +different layouts, not revisions to reconcile by shifting fields. Capture-backed +physiological cross-checks support the listed interpretations but do not establish +medical accuracy, wavelength identity, calibration, or universal sample timing. + +### WHOOP 4 realtime heart-rate record (type 40) + +The table below is **documented for this version** and corroborated by observed +WHOOP 4 notifications. Offsets are absolute in the reassembled WHOOP 4 frame. + +| Offset | Width | Field | Boundary | +|---:|---:|---|---| +| 4 | 1 | Packet type 40 | Family-specific envelope already verified | +| 5 | 1 | Frame sequence | Not a timestamp or request correlation | +| 6 | 4 | Unix/device seconds | **Capture interpretation**; correct wall time depends on clock state | +| 10 | 2 | Subseconds | **Retained field**; exact unit is not independently calibrated here | +| 12 | 1 | Heart rate | bpm | +| 13 | 1 | R-R count | Bounds-check against frame end before iterating | +| 14 + 2n | 2 each | R-R intervals | milliseconds; zero is a placeholder rather than a measured interval | + +The standard Heart Rate Service is a separate BLE source. It can serve as a +low-bandwidth alternative, but its values do not prove that proprietary type-40 +output or historical storage is healthy. + +### WHOOP 4 realtime raw layouts (type 43) + +The common prefix is **documented for this version**: command/subtype at 6, +record header at 7–14, Unix seconds at 15 and subseconds at 19–20. +After that prefix, select by validated payload length, which equals declared length minus 7; packet type alone is +not enough. + +| Payload length | Validation | Established layout | Unmapped region | +|---:|---|---|---| +| 1,917 | **Documented for this version; observed in device captures.** Declared length is 1,924. | HR at 21, R-R count at 22, four R-R values at 23–30; accelerometer X/Y/Z spans 89–688 as 100 × 3 × `i16le` from 89/289/489; gyroscope spans 692–1291 from 692/892/1092; tail 1292–1923 | 31–88 and 689–691 remain unresolved; tail semantics are not yet named | +| 1,921 | **Documented for this version; observed in device captures.** Declared length is 1,928. | Common time fields remain at 15–20; an additional optical header occupies 21–41; one signed 24-bit little-endian AC-coupled optical waveform starts at 42 with stride 4, up to 419 samples | fourth stride byte, auxiliary bytes and tail semantics | + +**Observed in device captures:** the 1,917 layout produced about 100 samples per axis per +packet at roughly one packet per second. Accelerometer scaling `1/4096 g` matched +a gravity sphere fit; gyroscope scaling `2000/32768 degrees/s` matched bounded +720-degree rotations within the observation's turn-count precision. These are +commonly applied interpretations, not confirmed device parameters or a guarantee +for every sensor-board configuration. + +**Capture interpretation:** the 1,921 waveform showed a clean pulse-like signal +on-finger and flattened in air, supporting an optical/PPG role. It did not expose +four interleaved wavelength channels. Red, IR and ambient identities are therefore +not assigned to this live stream; byte 3 of each four-byte stride must be retained. + +Command 63 is the **capture-backed** output switch for the observed R10/R11 +stream. Commands 81/82 concern collection state and did not substitute for command +63 in the observed WHOOP 4 path. Command 105 is outside the documented 41.17.6.0 +command set and has no recorded observation; +commands 106/107 are the supported IMU stream SET/GET pair. An accepted write +does not prove packet production or persistence. + +### WHOOP 4 historical v24 + +This mapping combines a retained layout with device captures. Offsets are absolute +in the complete WHOOP 4 frame; version is byte 5. + +| Offset | Width/type | Field | Validation boundary | +|---:|---|---|---| +| 11 | 4/u32 | Unix seconds | capture-backed | +| 21 | 1/u8 | Heart rate | capture-backed bpm | +| 22 | 1/u8 | R-R count | frame bounds apply | +| 23 | 2 × count | R-R intervals | milliseconds; zero omitted | +| 33, 35 | 2/u16 each | green and red/IR-labelled raw optical scalars | labels come from the legacy layout; not calibrated wavelength proof | +| 40/44/48 | 4/f32 | gravity vector | capture cross-check near 1 g | +| 55 | 1/u8 | skin-contact state | zero is the retained off-wrist interpretation | +| 56/60/64 | 4/f32 | second gravity vector | physical distinction from first vector unresolved | +| 68/70 | 2/u16 | raw red/IR SpO2-adjacent scalars | not an SpO2 percentage | +| 72 | 2/u16 | skin-temperature raw ADC | not centidegrees; family-specific provisional conversion only | +| 74 | 2/u16 | ambient-light raw | uncalibrated | +| 76/78 | 2/u16 | LED-drive words | units and channel assignment unresolved | +| 80 | 2/u16 | respiration-adjacent raw | not breaths/minute | +| 82 | 2/u16 | signal-quality word | scale/polarity unresolved | + +One version-identified 41.17.6.0 offload contained 1,704 +CRC-valid v24 records; HR and R-R arithmetic agreed to roughly one bpm and gravity +magnitude was physiologically plausible. This validates decoding for that capture, +not cloud equivalence, medical accuracy or universal v24 output behavior. +Versions 12 and 24 share a retained layout map; that association is not a fresh +v12 device validation. + +### WHOOP 4 historical v25 and unknown versions + +**Documented for this version; observed in device captures:** depending on device +configuration, 41.17.6.0 produces v25 as an 84-byte complete frame with layout +byte 25 at 5, seconds at 11, a sensor block from 23 and signed movement values at +73/75/77. Interpreting those values as gravity divided by 16384 is observation-based, +not a confirmed device parameter. Forty-five mapped records had gravity magnitude about 0.94–0.99 g. No +per-second HR field is mapped; do not infer HR from the waveform. + +Versions 5/7/9, 12/24 and 25 have distinct documented maps. An otherwise unknown +WHOOP 4 version remains an unknown layout even if some values resemble v24. + +### WHOOP 4 sensor and record controls + +The documented 41.17.6.0 controls cover AFE, LED/TIA/bias, accelerometer, +gyroscope/IMU, fuel gauge, charger/cap-sense and board-dependent sensor-enable +revisions 7, 9, 12 and 19. Separate record controls exist for R7, R9, R10+R11, +R12/R24 and R19, plus AFE and IMU streaming. Saved and realtime output exists +for R7, R9, R10+R11, R12 and R24. These facts do not establish the complete +wire layouts, units, calibration, cadence or availability in every device state. + +The strap reports a step count and can recover from a count that remains zero or +unchanged; it also reports sleep/wake motion classifications and rolling motion +statistics. False-step suppression affects the reported count. Exact filters, +threshold units, orientation compensation, persistence and ground-truth accuracy +remain unknown. This counter +must not be equated with app or cloud steps without a version-labelled wire field +and a validation set. + + + +## WHOOP 5/MG + +The remaining R18/R20/R21/R22/R26 contracts apply to 50.42.1.0 unless a subsection +explicitly names an older device observation or interpretation convention. + +### Packet types, record layouts and integrity All offsets are absolute in the **reassembled WHOOP 5/MG format-1 frame**. Integers and IEEE-754 floats are little-endian unless stated otherwise. Verify @@ -27,20 +194,20 @@ a record can span BLE fragments. | 43 | Live sensor data; R16 and R17 ECG use a layout selector at byte 9. Type alone does not identify a waveform. | | 47 | Historical data; byte 9 selects R16, R17, R18, R20, R21, R26 or another record layout. Preserve unknown layouts. | | 48 | Event; WHOOP 5 event number at byte 10 and u32 event timestamp at byte 12. Variable payloads require event-specific handling. | -| 51 | Dedicated realtime IMU stream; [partial client layout](#dedicated-imu-stream-types-51-and-52), current strap producer unconfirmed. | -| 52 | Dedicated historical IMU stream; [partial client layout](#dedicated-imu-stream-types-51-and-52). Do **not** assume type 47/R21. | +| 51 | Dedicated realtime IMU stream; [documented count/span layout](#dedicated-imu-stream-types-51-and-52), current strap output unconfirmed. | +| 52 | Dedicated historical IMU stream; [documented count/span layout](#dedicated-imu-stream-types-51-and-52). Do **not** assume type 47/R21. | Packet number, record layout, command number and event number are separate namespaces. WHOOP 4 type-43 shapes must not be imported by shifting offsets alone. The 1,917-byte WHOOP 4 IMU and 1,921-byte optical variants do not define WHOOP 5 -records. Current START/END/COMPLETE messages use metadata type 49. Preserve type 56 compatibility where older supported-device evidence requires it; no universal release boundary is established. +records. Current START/END/COMPLETE messages use metadata type 49. Preserve type 56 compatibility where older supported-device observations require it; no universal release boundary is established. For R18/R20/R21/R26, byte 9 is the layout, u32 at 11 is a record index and u32 at 15 is a Unix-seconds timestamp. The index is not a timestamp: do not fill time gaps by counting records or assume rollover/reset behavior. Unmapped fields must remain opaque, not silently converted to zero measurements. -## Packet 40: live HR and R-R +### Packet 40: live HR and R-R | Offset | Width / type | Meaning | |---:|---|---| @@ -57,10 +224,10 @@ for multiplication. No packet-local quality flag or independent absolute timesta per interval is established. Do not reuse the unknown additional time field's scale from an unrelated clock command. -## R18: biometric summary +### R18: biometric summary The established summary shape is **124 bytes**, with CRC at 120. The following -measurement conventions include earlier NOOP decoding; they are not all physical +measurement conventions include earlier interpretations; they are not all physical sensor guarantees for every firmware version. | Offset | Width / encoding | Field, scale and validity | @@ -69,26 +236,26 @@ sensor guarantees for every firmware version. | 11 | 4 / u32 | Record index | | 15 | 4 / u32 | Unix seconds; reject implausible dates according to the application's time-range policy | | 22 | 1 / u8 | Heart rate, bpm; retain quality context from byte 36 | -| 23 | 1 / u8 | R-R count; NOOP reads at most four complete positive words | +| 23 | 1 / u8 | R-R count; the documented layout contains at most four complete positive words | | 24 + 2i | 2 / u16 | Up to four R-R words; 1/1024 s, same rounded-ms conversion as packet 40 | | 33 | 1 / u8 | Cardiac-adjacent flags, meanings unresolved | -| 36 | 1 / u8 | HR/R-R quality flags; bit 7 is the earlier NOOP validity interpretation, not independently established here for the reference baseline | -| 37 | 1 / u8 | Alternate HR in bpm; earlier NOOP convention uses byte-36 bit 7 as its acceptance gate; validity for the reference baseline remains unresolved | +| 36 | 1 / u8 | HR/R-R quality flags; bit 7 has an earlier validity interpretation not independently established here for the reference baseline | +| 37 | 1 / u8 | Alternate HR in bpm; an earlier convention treats byte-36 bit 7 as its validity signal; validity for the reference baseline remains unresolved | | 38 | 2 / u16 | R-R-adjacent packed word; unit/meaning unresolved | -| 41 | 4 / f32 | Dynamic, gravity-removed acceleration; g in NOOP's convention, accept finite values in [0,8] | +| 41 | 4 / f32 | Dynamic, gravity-removed acceleration; g in the retained convention, accept finite values in [0,8] | | 45, 49, 53 | 4 each / f32 | Gravity x/y/z, g; no established per-axis sentinel | | 57 | 2 / u16 | Selected cumulative step/motion counter; selection detailed below | | 59 | 2 / u16 | Cadence-like raw value, supplied from u8; high byte is zero | | 61 | 2 / u16 | Hardware counter when software override is active, otherwise zero | | 63 | 1 / u8 | Activity: 0 still, 1 walk, 2 run; other input classes become FF and must not be surfaced as those three classes | | 64 | 1 / u8 | 10 hex when software counter override is active, otherwise zero | -| 69, 71 | 2 each / i16 | Auxiliary thermal channels, raw/10 °C in NOOP; accept 0–60 °C | +| 69, 71 | 2 each / i16 | Auxiliary thermal channels, retained raw/10 °C interpretation; accept 0–60 °C | | 73 | 2 / u16 | Skin-temperature convention, raw/100 °C; accept 5–45 °C | | 75, 77, 79 | 2 each / u16 | Raw status words; no sleep-stage meaning established | | 81 | 1 / bitfield | Four two-bit groups; detailed below | | 82 | 1 / u8 | Sleep-adjacent raw byte; 80/A0 hex are candidate sentinels, not established physiological labels | | 106, 107 | 1 each / u8 | Optical baseline-like raw values; per-byte optical identity provisional | -| 108, 109 | 1 each / u8 | Optical amplitude-like values; simultaneous 128 is NOOP's signal-quality sentinel interpretation | +| 108, 109 | 1 each / u8 | Optical amplitude-like values; simultaneous 128 has been treated as a signal-quality sentinel | | 113 | 4 / f32 | Unknown finite float; zero may mean unset, no established quantity | | 120 | 4 / u32 | CRC32 over `[8,120)` | @@ -108,23 +275,23 @@ zero-filled convention in the available decoder coverage, and byte 104 a marker; these are not universal sentinels. Do not reject a future record solely because a previously constant tail changes. -### Step source, cadence and activity +#### Step source, cadence and activity -the normal value at 57 is the hardware pedometer count. When software +For this layout, the normal value at 57 is the hardware pedometer count. When software counter override is enabled, 57 carries the software count, 61 retains the hardware count and 64 becomes `0x10`. With no override, bytes 61–62 and 64 are zero. This -allows a client to retain both sources and avoid joining a change of source into a +allows a client to retain both sources and avoid turning a change of source into a false step delta. The hardware tuple contains count, cadence-like byte and activity class. A -cadence value is **not a documented steps-per-minute conversion**; even its -monotonic relationship to speed is not guaranteed. Preserve it raw. Neither +cadence value is **not a documented steps-per-minute conversion**; even a +consistent relationship to speed is not guaranteed. Preserve it raw. Neither counter is established as equal to the official app's aggregated step count. Rollover, reset and day boundaries remain unresolved; a timestamped counter must not be advertised as a midnight-reset daily total. Byte 63 also has older quality-oriented naming, so retain the raw byte with the selected activity label. -### Motion/rest state and override +#### Motion/rest state and override Byte 81 packs four independent two-bit values: @@ -147,55 +314,23 @@ SLEEP is not a mapping to light, deep or REM sleep, and is unrelated to processo power-saving sleep. Byte 75 is not a deep-sleep indicator. The record supplies neither a validated hypnogram nor an established production SpO₂ value. -## R18 quality-adjacent source-selection bits +### R18 quality-adjacent source-selection bits -The packed byte at frame 36 includes source-selection contributions. In the -traced producer, bit 4 is added when an alternate-source selection branch is -active, and bit 5 is added both there and under a subsequent hold condition. -The same byte also receives the low four bits of a separate source through an -OR operation, and another source can add bit 6. These contributions can coexist -with bits 4 and 5. They do not establish a single quality enum, a complete -validity mask or physiological labels. +The packed byte at frame 36 combines several independently observable fields. +Bit 4 marks active alternate-source selection. Bit 5 is set with bit 4 and can +remain set without bit 4 for up to nine further qualifying updates. The low four +bits carry a separate raw field, and bit 6 can be set independently; these values +can coexist. Preserve the complete raw byte. -The numeric selector has the following bounded transitions. Here `x` and `y` are -internal numeric scores, `-128` is missing input, and counts are calls, not seconds -or calibrated quality measures. +These relationships do not establish a single quality enum, a complete validity +mask, clinical quality, bit-7 validity or a sufficient rule for accepting or +discarding a reading. Bit 5 is not an instantaneous source-selection indicator, +and update counts are not a wall-clock duration. A separate byte in the flags word +is zero for nonpositive finite input; positive values are limited to 30–210 and +rounded to the nearest integer, with positive half values rounded upward. Its units +remain unresolved and the range is not proof of heart-rate or quality semantics. -| State | Selected transition rule | -|---|---| -| 0 | Wait ten count increments, then enter 1 for `x` in `[-127,12]`. | -| 1 | Return to 0 for `x > 20`; otherwise missing `x` or `y` retains 1, and `y > x + 7` enters 2. | -| 2 | Wait ten increments, then retain 2 only for `x` in `[-127,20]`, `y != -128`, and `y > x + 7`. | - -State 2 contributes the override only with the additional enable predicates; -these transitions alone are not a client readiness test. - -The hold counter is set to 10 by that branch. Once the branch stops, it is -reduced before testing; bit 5 can therefore remain without bit 4 for nine further -qualifying updates. Continued selector state 1 or 2 is required; state 0 stops this -contribution immediately. Update counts are not a wall-clock duration, and this -is not an unconditional ten-record grace period. - -Preserve the raw byte. These contributions do not establish clinical quality, -bit 7 validity, or a sufficient rule for accepting or discarding a reading. - -The numeric inputs behind the source-selection contributions in bits 4/5 have -separate histories. After a history reset, the first four enabled updates supply -a missing-input value. Updates 5–59 use a quantized cumulative mean of a -transformed internal score; update 60 starts an approximately 2% new / 98% retained -state smoother. These are update counts, not seconds or a sample-rate guarantee. -A configuration-change path resets both histories. Combined with the selector -and hold counter, this prevents treating bits 4/5 as an instantaneous quality rank. -The score's physiological meaning remains unspecified. - -A separate contribution to the flags word is an internal cached numeric value -shifted by 8 and ORed with other contributions. For finite inputs it is zero when -nonpositive; positive inputs are clamped to 30–210 and rounded to the nearest -integer, with positive half values rounded upward. Its units and other writers -to the resulting byte remain unresolved. Do not interpret the numeric range as -proof of heart-rate or quality semantics. - -## R20: optical blocks +### R20: optical blocks R20 is exactly **2,140 bytes**: a 26-byte header, five 422-byte blocks, then CRC32 at 2136 covering `[8,2136)`. It is not a checksum over only the blocks. Layout is @@ -233,29 +368,30 @@ For a constructed count-only example, block counts `[12,0,0,12,12]` represent `3 × 2 × 12 = 72` populated values, despite 500 available positions. This example contains no measured samples. -### Configuration and conditional routing +#### Configuration and conditional routing The fourth block (zero-based block 3) has an alternate source pair. When the primary source has no samples, a fallback source supplies that block, including its count, and marker bit 0 is set. Do not interpret the fourth block as a permanently fixed optical channel or its marker as a quality verdict. -On the known configuration-to-metadata producer path, drive/configuration values -are quantized as +In observed configuration metadata, drive/configuration values are quantized as `(((input + 5) mod 2^32) // 10) mod 2^16`: the addition wraps as u32 before unsigned division, then the result is stored as u16. This rounding/truncation does not establish milliamps or another upstream physical unit. Historical conventions include ranges 16/32 and offsets -in multiples of 800. In the first-block configuration join, accepted offset +in multiples of 800. In the first block, accepted offset settings 0/8000/16000/24000 produce signed metadata values 0/800/1600/2400. -This join does not establish every block's complete routing or a physical unit. +This relationship does not establish every block's complete routing or a physical unit. A zero-drive fourth block has served as a dark control; that pattern is not a guarantee under every routing configuration. The two slots share one header and remain **A/B**, not red/infrared/green. Detector geometry, wavelength, source enums and calibrated drive/range/offset units remain unresolved. -## R21: six-axis IMU +### R21: six-axis IMU -R21 has fixed length **1,244 bytes**, with CRC32 at 1240 covering `[8,1240)`. +R21 and the [dedicated IMU streams](#dedicated-imu-stream-types-51-and-52) use the +[ICM-45686 IMU](PROTOCOL_WHOOP5.md#whoop5-icm-45686). R21 has fixed length +**1,244 bytes**, with CRC32 at 1240 covering `[8,1240)`. Layout is 21 at 9 and the marker at 10 commonly `0x80`; neither marker alone proves measurement quality. Sequence and Unix-seconds base time are at 11 and 15. @@ -278,11 +414,9 @@ The six columns each reserve 200 bytes. Counts are independent u8 values widened to u16, so their high bytes are zero in this version. All column capacity remains present when counts are smaller. Bounds-check each count against 100 and consume only that group's valid values. Do not require accelerometer and gyroscope counts -to be equal merely because earlier NOOP decoding accepted only 100/100 buffers. -Keep that existing strict decoder gate distinguishable from the broader record -capacity contract. +to be equal; the record capacity permits independent counts up to 100. -NOOP’s earlier IMU convention scales acceleration as `raw / 4096` g, +An earlier IMU convention scales acceleration as `raw / 4096` g, gyroscope as `raw * 2000 / 32768` degrees/s, and places sample i at `base_time + i/100` for a 100 Hz, one-second buffer. The fixed layout does not independently establish those physical settings or a timing rule for @@ -293,9 +427,9 @@ No per-sample quality bit, axis-to-strap/body geometry, counter rollover, timest jitter rule or active range configuration is established here. Structurally valid six-axis data is not proof of a body orientation. -## Inertial record timestamps +### Inertial record timestamps -layout 21 is carried by live packet 43 and historical +In this version, layout 21 is carried by live packet 43 and historical packet 47. Its little-endian timestamp has Unix seconds at frame offset 15 and a u16 fraction at offset 19. Combine them as `seconds + fraction / 32768`. @@ -305,56 +439,55 @@ For valid clock readings, fractional words range from 0 to 32440. This statement covers layout 21; it does not establish timing jitter, clock validity or the fractional scale of other layouts. -No matching strap producer is established for packets 51/52. The [partial client -layout](#dedicated-imu-stream-types-51-and-52) below provides separate count/span +No matching strap output is established for packets 51/52. The [documented +count/span layout](#dedicated-imu-stream-types-51-and-52) below provides separate bounds; do not decode these packets as layout 21. -Command 106 changes requested live motion state, which is staged and applied later. Successful driver application enables the packet 43/layout 21 publisher. Failure or rapid opposite requests can leave active and requested state different; see [collection coordination](PROTOCOL_CONFIGURATION.md#collection-and-live-stream-coordination). No packet 51/52 producer is established by this route. +Command 106 changes requested live motion state, which can take effect after the +acknowledgement. Successful application enables packet 43/layout 21 output. Failure +or rapid opposite requests can leave requested state and observed output different; +see [collection coordination](PROTOCOL_CONFIGURATION.md#collection-and-live-stream-coordination). +No packet 51/52 output is established by this route. -## R22 inner version +### R22 inner version -the R22 inner version is byte 21 of the complete frame, followed +On the wire, the R22 inner version is byte 21 of the complete frame, followed by subversion at byte 22. The frame remains 188 bytes across the selected version -paths. These bytes are distinct from layout 22 at byte 9 and outer format tag 3 -at byte 6. [Version preferences](PROTOCOL_CONFIGURATION.md#r22-version-preferences) +paths. These bytes are distinct from layout 22 at byte 9. In format 1, bytes 6–7 +contain the CRC16-Modbus value over bytes 0–5; there is no format tag there. Format 2 +instead carries an unnamed marker with value 3 at byte 34. [Version preferences](PROTOCOL_CONFIGURATION.md#r22-version-preferences) can fall back or use queued data. Preserve unknown versions; neither a preference name nor the fixed wrapper size supplies the full body schema. No packet 51/52 layout follows from this R22 selection path. -For the R18 quality byte at frame offset 36, the reference baseline contract combines -multiple bit contributions rather than a single established classifier enum. -Preserve the raw byte. This does not establish clinical meanings or confirm the -earlier bit-7 validity interpretation for this version. - -## R22 version 9 queued channels and sample encoding +### R22 version 9 queued channels and sample encoding -the v9 body contains a channel identifier at complete-frame byte 141. -The current-output path uses identifier 0 and stages bodies for identifiers 1..5. +For this layout, the v9 body contains a channel identifier at complete-frame byte 141. +Current output uses identifier 0; retained records use identifiers 1..5. These identifiers distinguish numeric input channels; their physical mapping and units remain unresolved. -The current-versus-replay decision tests the first 32-bit input words of numeric -channels 1 and 2. Both zero selects replay; otherwise current output is built. +When the first 32-bit words of numeric channels 1 and 2 are both zero, the strap +can replay retained output; otherwise it emits current output. These are value tests, not valid-count or complete-sensor-availability tests. A replayed body does not prove that all current sensor inputs were absent. -When this predicate switches to replay, one saved body is emitted -per preparation call in channel order **1,2,4,5,3**, draining each channel before -the next. A saved body is not necessarily a fresh reading. Empty queues produce +During replay, one retained body is emitted at a time in channel order +**1,2,4,5,3**, exhausting each channel before the next. A retained body is not +necessarily a fresh reading. When no retained record is available, the strap emits **version 4**, even when version 9 has priority in the configuration. Always decode the actual inner version at byte 21. -The described preparation/replay path finally compares version and subversion -together as a little-endian 16-bit number. Values above 9 become version 1, +The emitted version and subversion are also interpreted together as a little-endian +16-bit number. Values above 9 become version 1, subversion 0: 9/1 is normalized, whereas 0/0 is unchanged by this check. This is a -producer rule, not a client whitelist or a rule for every R22 builder. +documented output rule, not a client whitelist or a rule for every R22 variant. -Each of the five traced queues has 60 slots in its normal count range. At -capacity, new writes replace the last slot and retain the first 59; this is not -a rotating window of the most recent 60 records. Replay order is not a promise -of chronological order across channels. Adding current data does not itself -rewind replay cursors. No maximum replay age or guaranteed number of delivered -records is established. +The strap keeps up to 60 queued records for each of the five channels. At capacity, +a new record replaces the last position while the first 59 remain; this is not a +rotating window of the most recent 60 records. Replay order is not a promise of +chronological order across channels. New current data does not restart an ongoing +replay. No maximum replay age or guaranteed number of delivered records is established. For emitted version 9/subversion 0, offsets below are from the complete 188-byte frame; the body starts at byte 21: @@ -370,8 +503,8 @@ frame; the body starts at byte 21: | 35 | 98 | 49 little-endian signed 16-bit adjacent-sample differences | | 133 | 4 | Raw metadata; meaning unresolved | | 137 | 2 | Packed channel-selected metadata; [subfields](#r22-version-9-metadata-refinement) below | -| 139 | 1 | Predicate contribution on the selected producer path; meaning/freshness unresolved | -| 140 | 1 | Zero in this version's builder | +| 139 | 1 | Raw 0/1 field; meaning/freshness unresolved | +| 140 | 1 | Zero in this documented layout | | 141 | 1 | Numeric channel identifier 0..5 | | 142 | 42 | Opaque tail; do not assume zero | @@ -383,48 +516,46 @@ is lossy. Preserve the initial 32-bit pattern. Neither the encoding nor its channel identifier establishes calibrated units, physical signedness or sample frequency. The 42-byte tail is outside the sample encoding. -Keep body bytes 121–162 opaque. The output uses retained storage, and other body -versions write within this region. A version 9 write does not itself clear those -bytes. Nearby working-buffer and replay-pool clearing does not establish that -this output tail is zero. Ignore the tail when decoding version 9; do not use its +Keep body bytes 121–162 opaque. Other body versions can place values within this +region, and a version 9 record does not guarantee that the tail is zero. Ignore +the tail when decoding version 9; do not use its contents as a freshness marker or as extra sample values. The R18 validity convention and v8 remain unresolved. The v9 addition does not -define packets 51/52; their [partial client contract](#dedicated-imu-stream-types-51-and-52) is separate. +define packets 51/52; their [documented count/span layout](#dedicated-imu-stream-types-51-and-52) is separate. -## R22 version 9 metadata refinement +### R22 version 9 metadata refinement The little-endian word at complete-frame bytes 137..138 (body 116..117) is **packed metadata**, not a scalar gain, amplitude or quality score. Preserve its raw value alongside any extracted subfields. -For the current producer, bits 0..1 identify metadata group 0, 1 or 2. Channel IDs +In current observations, bits 0..1 identify metadata group 0, 1 or 2. Channel IDs 0/1/2 use group 0; IDs 3/4 use group 1; ID 5 uses group 2. Bit 2 is an additional state -contribution for group 2; bit 3 is zero in this producer. The channel ID remains +contribution for group 2; bit 3 is zero in this output. The channel ID remains at frame 141 (body 120), and these group numbers do not establish physical wavelengths or electrode assignments. -When the metadata source is available, bits 4..5 and 6..7 contain two separate +When this metadata is available, bits 4..5 and 6..7 contain two separate 2-bit values and bits 8..11 contain a 4-bit value. Their individual meanings remain unspecified. The upper nibble combines shifted source bytes; do not assign four independent boolean meanings until those source values are defined. -When the source is unavailable, the producer emits `group_tag | 0x0c00`. -**That pattern is not a unique availability indicator:** the available path can +When it is unavailable, the strap emits `group_tag | 0x0c00`. +**That pattern is not a unique availability indicator:** available metadata can produce the same word. Do not reject a record solely because its metadata equals that pattern. -Frame 139 (body 118) has a producer that extracts one flag bit and stores it as 0 -or 1; the meaning of that predicate remains unspecified. This does not guarantee -freshness on every record: the preparation path can be skipped, and queued v9 -records carry their saved metadata. The 42-byte tail at frame 142..183 remains +Frame 139 (body 118) carries one raw flag bit as 0 or 1; its meaning remains +unspecified. This does not guarantee freshness on every record, and replayed v9 +records carry their retained metadata. The 42-byte tail at frame 142..183 remains opaque, with no universal zero guarantee. -## R26: compact optical window +### R26: compact optical window R26 is exactly **88 bytes**, CRC at 84 covering `[8,84)`. It carries an optical base value plus **24 adjacent deltas**, not 24 independent absolute readings. -NOOP models the 25-sample window as one second; physical wavelength and calibrated -sample units remain unresolved. +A one-second interpretation has been used for the 25-sample window; physical +wavelength and calibrated sample units remain unresolved. | Offset | Width / encoding | Meaning | |---:|---|---| @@ -458,8 +589,8 @@ This example is arithmetic, not a waveform capture. The burst identifier is an actual two-byte field and increments when acquisition enters a new burst; it is not the record index, a channel selector or a ring-slot -index. Some NOOP paths omit zero, but zero remains possible after wrapping and is -not established as an invalid wire value. Byte 12 belongs to the record index and +index. Zero remains possible after wrapping and is not established as an invalid +wire value. Byte 12 belongs to the record index and must not be interpreted as a wavelength either. A buffered window retains its own index/time while awaiting delivery, so receive @@ -469,19 +600,18 @@ clinical quality vocabulary. This record does not identify simultaneous red and infrared channels, and carries no established R-R interval field. Do not derive SpO₂ from unassigned channels. -## Dedicated IMU stream types 51 and 52 +### Dedicated IMU stream types 51 and 52 -Packet types 51 and 52 share the following partial client contract for app -the [client baseline](PROTOCOL.md#scope-and-compatibility). A matching strap publisher and valid captured frames remain -unconfirmed. Do not substitute the R10, R21 or R22 layout for these types. +Packet types 51 and 52 share the following documented count/span layout. Valid +captured frames from a matching strap output remain unconfirmed. Do not substitute +the R10, R21 or R22 layout for these types. -On this decoder's frame path, the complete-frame offsets 24 and 26 contain -little-endian unsigned 16-bit accelerometer and gyroscope counts, A and G. -The decoder computes planar array starts as follows: +In this layout, complete-frame offsets 24 and 26 contain little-endian unsigned +16-bit accelerometer and gyroscope counts, A and G. The planar arrays begin at: | Array | Complete-frame byte offset | | --- | --- | -| Accelerometer X | `28` (inferred from the offset arithmetic) | +| Accelerometer X | `28` | | Accelerometer Y | `28 + 2*A` | | Accelerometer Z | `28 + 4*A` | | Gyroscope X | `28 + 6*A` | @@ -496,7 +626,7 @@ Sample signedness, physical scale, cadence, timestamp fields and normal count limits remain unspecified. Preserve these packets as opaque when that frame contract cannot be established. -## Implementation boundaries +### Implementation boundaries Keep versioned shape checks separate from physical interpretation. Counts determine valid values; fixed lengths determine buffer capacity. Do not treat padding, @@ -506,13 +636,13 @@ new firmware version cannot silently inherit an incompatible decoder. These specifications do not make packet 51/52 equivalent to R21, turn ECG into an optical record, or make band-state SLEEP an external sleep stage. They provide -parser and application contracts; remaining calibration, hardware and timing +decoding boundaries; remaining calibration, hardware and timing uncertainties require version-specific validation before stronger user-facing claims. -## Constructed arithmetic checks +### Constructed arithmetic checks Run `python3 docs/protocol-examples/validate_examples.py` from the repository root. The [standalone example](protocol-examples/validate_examples.py) checks clipped R26 reconstruction and R-R conversion with invented values, alongside ECG field checks. It does not validate captured records, CRC implementation, firmware -execution, physical calibration or NOOP integration. +execution, physical calibration or application integration. diff --git a/docs/PROTOCOL_TRANSPORT.md b/docs/PROTOCOL_TRANSPORT.md index 73e2d89de9..f262aaa198 100644 --- a/docs/PROTOCOL_TRANSPORT.md +++ b/docs/PROTOCOL_TRANSPORT.md @@ -2,9 +2,137 @@ Applicability: [central scope and compatibility](PROTOCOL.md#scope-and-compatibility). -This extends [the protocol entry page](PROTOCOL.md) with WHOOP 5/MG contracts. Earlier WHOOP 4 and 5 observations are labeled separately. A defined command does not establish identical behavior on every hardware variant or connection state. See the [complete command reference](PROTOCOL_COMMANDS.md) for individual operations. - -## Format 1 framing +This extends [the protocol entry page](PROTOCOL.md) with generation-specific +contracts. Earlier WHOOP 4 and WHOOP 5/MG observations are labeled separately. +A defined command does not establish identical behavior on every hardware variant +or connection state. See the [complete command reference](PROTOCOL_COMMANDS.md) +for individual operations. + +## Contents + +- [WHOOP 4](#whoop-4) + - [WHOOP 4 frame and response procedure](#whoop-4-frame-and-response-procedure) + - [WHOOP 4 history lifecycle](#whoop-4-history-lifecycle) + - [WHOOP 4 battery sources](#whoop-4-battery-sources) +- [WHOOP 5/MG](#whoop-5mg) + - [Format 1 framing](#format-1-framing) + - [Responses and correlation](#responses-and-correlation) + - [Format 2 boundary](#format-2-boundary) + - [Clock and identity contracts](#clock-and-identity-contracts) + - [History sequencing and storage ownership](#history-sequencing-and-storage-ownership) + - [Scheduled-control timing](#scheduled-control-timing) + - [Complete response bodies](#complete-response-bodies) + - [Battery level — command 26](#battery-level--command-26) + - [Hello — command 145](#hello--command-145) + - [Battery pack — command 151](#battery-pack--command-151) + - [Data range — command 34](#data-range--command-34) + - [Historical synchronization: boundaries, retries and range interpretation](#historical-synchronization-boundaries-retries-and-range-interpretation) + - [Battery replies and cached accessory information](#battery-replies-and-cached-accessory-information) + - [Interruption and recovery](#interruption-and-recovery) + - [Connection and error recovery boundaries](#connection-and-error-recovery-boundaries) + + + +## WHOOP 4 + +The following boundaries combine version-identified WHOOP 4 observations with +supported interoperability behavior. Neither imports the WHOOP 5/MG contract, and a parsed response does +not by itself prove a persistent or physical effect. + +| Boundary | WHOOP 4 contract | +|---|---| +| Complete frame | **Observed in device captures:** `aa`, `length:u16le`, CRC8 over the two length bytes, inner record at 4, CRC32 trailer at `length`; complete size `length + 4` | +| Minimum accepted command frame | **Documented for this version:** the inner record needs type, sequence and command; a zero-payload command therefore has declared length 7 and complete size 11 | +| Response prefix | **Observed in device captures:** command at 6, originating request sequence at 7, result at 8, command body at 9 | +| Fragmentation | **Observed in device captures:** notifications on characteristic `…0005` can split frames; reassemble by the WHOOP 4 declared total and validate both CRCs before decoding | +| Clock | Commands 10/11 are outside the documented 41.17.6.0 command set. On some devices one of the two SET_CLOCK forms was observed to latch; read back to confirm | +| History metadata | **Retained layout + capture:** type 49 carries START/END/COMPLETE. `HISTORY_END` data begins at frame 7; its acknowledgement block is frame 17–24 | +| History records | **Observed in device captures:** type 47 layouts vary by emitted version and length. Persist the full accepted frame and rejected-record data before command-23 acknowledgement | +| Battery | **Observed in device captures:** battery percent from command 26 is `u16le / 10`. Command 98 returns the cached 25-byte extended record; event 63 announces extended battery information, while event 98 means high-frequency sync disabled. There is no WHOOP 4 command-151 fuel-gauge contract | +| Identity | **Observed in device captures:** DIS serial is not used by this profile. A command-35 Harvard hello layout contains a ten-byte serial field and adjacent key and signature material; logs must redact the sensitive block | + +WHOOP 4 exposes the documented BLE, command, history and sensor interfaces; +communication between the device's processors is outside this reference. + +Result values 0/1/2/3 have been observed in the same failure/success/pending/ +unsupported roles, but a result byte alone does not import the WHOOP 5/MG response +body or asynchronous lifecycle. Multiple CRC-valid replies to one range request +have been captured; correlate by command, origin sequence, connection and request +lifecycle rather than taking the first matching command number. + +### WHOOP 4 frame and response procedure + +1. Discard bytes before the first `aa`, then wait for at least + four bytes before reading the declared length. Apply a bounded maximum before + allocating or waiting indefinitely. +2. The declared length runs from packet type at byte 4 through + the final CRC32; complete size is `length + 4`. CRC8 uses polynomial `0x07` + over the two little-endian length bytes. CRC32 covers the inner bytes beginning + at byte 4 and excludes its own four-byte trailer. +3. **Observed in device captures:** route packet type 36 as a command response and use + `(command, origin sequence, connection generation)` as the minimum correlation + key. The byte at 5 is the response frame's own sequence and is not sufficient. +4. Preserve unknown result values and short bodies as raw + diagnostics. Do not read through the declared boundary into CRC bytes. +5. **Observed in device captures:** one request may emit pending followed by a final + response, or more than one CRC-valid response. A local wait limit closes local + waiting; it does not prove device cancellation. + +Sequence reuse after eight-bit wrap, late notifications after reconnect and the +phone-visible duplicate-request policy remain **unknown**. + +### WHOOP 4 history lifecycle + +**Observed in device captures:** command 22 with body `00` begins the legacy history +offload without requiring the WHOOP 5/MG data-range preflight. Type-49 metadata +marks START, END and COMPLETE; type-47 frames between those markers are records. +For END, retain the eight bytes at frame offsets 17–24 exactly and acknowledge +with command 23 body `01 || acknowledgement_block[8]` only after durable local commit. The +first word is the trim cursor; the second word must be retained as wrap state +rather than regenerated. + +The chunk durability boundary covers accepted records, rejected-layout data, +raw-frame retention and the cursor update. If any part fails, withhold the +acknowledgement. COMPLETE ends the session but does not retroactively commit +an open chunk. Command 97 is not a required precondition for command 22. + +On 41.17.6.0, command 96 uses a revision/legacy byte followed by `period:u16le` +and `duration:u16le`; period is at least 60 seconds and duration is at most 28,800 +seconds. Command 97 evaluates no body fields. Both return result 1 without a body. +Event 97 reports high-frequency sync enabled, and event 98 reports it disabled. + +The history protocol includes metadata start/end, data/event phases, read-page +plus wrap count, trim/read cursors, abort-on-disconnect, phase timeouts, result +retries, burst mode and configurable burst size. Commands 20, 22 and 23 are outside +the documented 41.17.6.0 command set but were observed working in device captures +on that version, including type-47 delivery and `HISTORY_END` acknowledgement. + +**Open questions:** replay after disconnect, maximum chunk size, exact ring-wrap +semantics, device retry timing, cursor durability and whether an unacknowledged END +is retransmitted byte-for-byte have not been established for 41.17.6.0. + +### WHOOP 4 battery sources + +| Source | Observed layout | Use boundary | +|---|---|---| +| Command 26 | **Observed in device captures:** response body begins with `u16le` tenths of a percent | Primary proprietary state of charge; validate result and body length first | +| Event 3 | **Observed in device captures:** state of charge `u16le/10` at frame 17, millivolts `u16le` at 21, charging bit 0 at 26 | Dense pushed observation; offsets are WHOOP 4 event-frame absolute | +| Command 98 | With a valid cache, result 1 carries 25 bytes: `u8`, seven `u16le`, two `u32le`, then `u16le`; pack voltage in millivolts is the third `u16le` at body offset 5. An empty cache returns result 0 | Cached extended battery record; event 63 is `EXTENDED_BATTERY_INFORMATION`, while event 98 means high-frequency sync disabled | +| Standard Battery Service | **Observed in device captures:** some WHOOP 4 devices report a constant 100 | Do not use as authoritative charge when the proprietary source is available | + +No listed source establishes cell health, remaining runtime, charging current, +temperature compensation or calibration accuracy. Conflicting observations should +be retained with source and receive time rather than silently averaged. + + + +## WHOOP 5/MG + +Unless a subsection says otherwise, Format 1, Format 2 and the complete response +bodies below apply to **WHOOP 5/MG 50.42.1.0**. They do +not describe WHOOP 4 merely because some inner packet and command numbers overlap. + +### Format 1 framing All multibyte integers below are little-endian. Offsets are from the beginning of the complete frame. @@ -23,13 +151,13 @@ All multibyte integers below are little-endian. Offsets are from the beginning o Construct the body as packet type, sequence, command and command-specific bytes; append zero bytes until its size is divisible by four. The declared length is that padded size plus four, and the complete frame size is the padded size plus twelve. Padding is not a semantic argument: an explicit request byte `00` and a missing argument must not be treated as interchangeable merely because padding can make their frames look alike. -The baseline format-1 acceptance constraints require a complete frame longer than 15 bytes, exact agreement between declared and supplied length, and `(declared length − 4)` divisible by four. Do not require one constant value for bytes 4–5. Header CRC is reflected Modbus CRC16, polynomial `0xa001`, initial value `0xffff`. NOOP format-1 bodies use the standard reflected CRC32 convention, polynomial `0xedb88320`, initial and final XOR `0xffffffff`, including padding. +The baseline format-1 acceptance constraints require a complete frame longer than 15 bytes, exact agreement between declared and supplied length, and `(declared length − 4)` divisible by four. Do not require one constant value for bytes 4–5. Header CRC is reflected Modbus CRC16, polynomial `0xA001`, initial value `0xFFFF`. Format-1 bodies use the standard reflected CRC32 convention, polynomial `EDB88320h`, initial and final XOR `FFFFFFFFh`, including padding. -NOOP's existing generic verifier is more permissive about minimum length, trailing bytes and format selection. Reassemble complete frames first, distinguish outer formats, and validate the exact selected frame rather than letting a permissive checksum check imply support for another format. Parser errors for unsupported format, size, header CRC and body CRC are local failures, **not** command-result values on the wire. +Reassemble complete frames first, distinguish outer formats, and validate the exact selected frame. Unsupported format, size, header CRC and body CRC are local failures, **not** command-result values on the wire. -WHOOP 4 uses its separate envelope: `aa`, two-byte length, CRC8 over those two length bytes, then the inner body at offset 4 and its CRC32. Complete size is declared length plus four. Keep the family-specific GATT and fragment handling in [the entry page](PROTOCOL.md#2-frame-envelope). +WHOOP 4 uses a separate envelope, specified in the [WHOOP 4 envelope](PROTOCOL_WHOOP4.md#whoop-4-envelope). -## Responses and correlation +### Responses and correlation A format-1 command response has type 36 at offset 8, a generated response sequence at 9, command at 10, **originating request sequence at 11**, result at 12, and command body at 13. The request-origin byte is the correlation field; the response frame's sequence is not the request echo. Match command and origin, with connection/session context and a bounded outstanding-request policy. Sequence wrap and duplicate responses remain possible. WHOOP 4's corresponding command/origin/result/body offsets are 6/7/8/9. @@ -41,13 +169,16 @@ A format-1 command response has type 36 at offset 8, a generated response sequen | 3 | Unsupported | This command is not supported in the applicable command context. | | Other | Unknown | Preserve the numeric value; do not coerce it to success. | -Check lengths before accessing the response prefix or body; the CRC trailer and outer padding are not response fields. The first command-body byte is command-specific: for several revision-1 controls it is a revision marker, whereas command 26 begins a four-byte whole-percent value in this version. It is not a universal success flag or state echo. The 88 unsupported command IDs in the reference return result 3 with an empty semantic body command context. +Check lengths before accessing the response prefix or body; the CRC trailer and outer padding are not response fields. The first command-body byte is command-specific: for several revision-1 controls it is a revision marker, whereas command 26 begins a four-byte whole-percent value in this version. It is not a universal success flag or state echo. For WHOOP 5/MG 50.42.1.0, the 88 `U` command IDs return result 3 with an empty semantic body command context. -NOOP increments an eight-bit request sequence before sending and wraps it. Resetting it on disconnect is a client policy, not a guarantee that a late notification belongs to the new session. A command can produce multiple responses, and a retransmitted request is not automatically safe to execute twice. +The request sequence is eight bits and can wrap. Sequence reuse across a disconnect +does not prove that a late notification belongs to the new session. A command can +produce multiple responses, and a retransmitted request is not automatically safe +to execute twice. -## Format 2 boundary +### Format 2 boundary -A second outer format exists, but its runtime availability and session negotiation are unresolved. Normal NOOP traffic remains format 1; do not switch formats automatically. For incoming format-2 commands, inner type 7 is at offset 25, the normalized request sequence comes from byte 17, command from byte 29 and payload starts at 31. The wider outgoing fields do not establish a wider incoming command namespace. +A second outer format exists, but its runtime availability and session negotiation are unresolved. Observed ordinary traffic uses format 1; do not assume format 2 without an established session condition. For incoming format-2 commands, inner type 7 is at offset 25, the normalized request sequence comes from byte 17, command from byte 29 and payload starts at 31. The wider outgoing fields do not establish a wider incoming command namespace. | Reply offset | Width | Meaning | |---:|---:|---| @@ -66,45 +197,45 @@ A second outer format exists, but its runtime availability and session negotiati | 39 | 1 | Marker 1 | | 40 | variable | Command response body | -Unassigned gaps are zero in this reply layout. The reply length calculation narrows `(body length + 32)` to eight bits. This is not an unrestricted large-payload interface; behavior for oversized bodies remains unresolved. Format-1 offsets must never be applied to this format. +Unassigned gaps are zero in this reply layout. Reply lengths above 255 bytes are not documented for this format. This is not an unrestricted large-payload interface; behavior for oversized bodies remains unresolved. Format-1 offsets must never be applied to this format. -## Clock and identity contracts +### Clock and identity contracts SET_CLOCK uses revision 1, Unix seconds `u32`, and fractional ticks `u16` in units of 1/32768 second. Use a canonical fractional value 0–32767. The stored clock precision is hundredths: the fraction is converted using `floor(ticks × 100 / 32768)`. The reply is a one-byte revision-1 body with success or failure. Behavior of noncanonical fractional values is not specified here. -GET_CLOCK takes revision 1 and returns seven bytes: revision, seconds `u32`, fractional ticks `u16`. Returned ticks are `floor(hundredths × 32768 / 100)`, so a set/get round trip can lose precision. An unsupported revision returns failure with revision 1 and zero time. A clock-read failure can also produce zero time with a success result; there is no independent validity flag. Applications must not treat success alone as evidence of a valid wall clock. - -One initialization path consumes a pending saved time. It requires a valid stored -request, saved seconds strictly above Unix timestamp `1293840001`, and a saved time newer -than the current valid seconds value. These eligibility comparisons use seconds, -not the fractional field. The fractional field is supplied to the time setter -when restoration is attempted. +GET_CLOCK takes revision 1 and returns seven bytes: revision, seconds `u32`, fractional ticks `u16`. Returned ticks are `floor(hundredths × 32768 / 100)`, so a set/get round trip can lose precision. An unsupported revision returns failure with revision 1 and zero time. A clock-read failure can also produce zero time with a success result; there is no independent validity flag. Applications must not treat success alone as proof of a valid wall clock. -The pending saved time is cleared after the selected attempt, including when -the time setter reports failure; rejected stale or invalid saved times are also -cleared. Clearing the persistent request can itself fail. Applications should -read the current device time after reconnecting before relying on it for -scheduled operations. A pending saved time is not a guarantee that initialization -restored the clock or will retry until it succeeds. +A time set while the strap is unavailable can be stored and applied later. Such a +stored time is only applied when its seconds are strictly above Unix timestamp +`1293840001` and newer than the strap's current valid seconds; these comparisons +use seconds, not the fractional field. The stored time is applied at most once, +whether or not the application succeeds. Applications should read the current +device time after reconnecting before relying on it for scheduled operations. A +stored pending time is not a guarantee that the clock was restored or that the +attempt will be repeated. The deprecated low-number clock pair has different legacy request shapes. Do not substitute its seconds-plus-four-zero-bytes body or its WHOOP 4 response offsets for the new pair. LINK_VALID returns success with a fixed 13-byte NUL-terminated acknowledgement; it is not an identity token. GET_HELLO accepts revisions 1 and 3, initially returning pending with respectively 107 or 111 body bytes, the revision echoed and other bytes zero. Invalid revision returns failure with a 107-byte body beginning with revision 1. The final layout is specified below; earlier Hello decoder offsets are not universal. Hello and battery-pack records may contain identifiers; a protocol decoder should extract only the fields needed by its feature. -## History sequencing and storage ownership +### History sequencing and storage ownership -The existing [backfill state machine](PROTOCOL.md#7-historical-data-offload-backfill) remains the operational contract. WHOOP 5/MG clients request the range before history and wait for success or a two-second client fallback; WHOOP 4 clients can request history directly. The timeout is an application choice, not a device timing promise. The current range request first returns pending with an empty body; its final 65-byte body is specified below, with opaque cursor and timestamp roles retained. Lack of a response does not mean that history is empty. +The boundaries in this section are the operational history contract. WHOOP 5/MG +requests the range before history, while WHOOP 4 can request history directly. +The current range request first returns pending with an empty body; its final +65-byte body is specified below, with opaque cursor and timestamp roles retained. +Lack of a response does not mean that history is empty. History transmission uses an explicit `00` request byte. Metadata start/end/complete values are 1/2/3. Packet types 49 and 56 both participate in known history routing; the current START/END/COMPLETE path uses type 49; other-version routing remains separately scoped. A command acknowledgement is not delivery of a chunk. For each HISTORY_END, preserve its eight-byte acknowledgement block verbatim. The first four bytes are the trim cursor in known layouts; the second four reflect write-wrap state in this version. Send the history-result body `01` followed by that block **only after committing** the chunk's decoded records, rejected-record diagnostics and cursor locally. Do not reconstruct that block from decoded timestamps. Maintain arrival order where duplicate handling and cursor association depend on it. -A successful chunk acknowledgement can allow the strap to reclaim history. Local storage failure therefore withholds it. An end marker closes a chunk; transfer-complete closes the overall session. Timeouts do not authorize acknowledging an uncommitted open chunk. NOOP's watchdog, retries and backlog handling are client policies; cross-disconnect replay guarantees remain unknown; local retry and cursor roles are specified below. ABORT_HISTORICAL_TRANSMITS is a stop request, not a trim, and local cleanup must not depend on a reply arriving. FORCE_TRIM and SET_READ_POINTER are separate invasive cursor mutations and are not substitutes for normal chunk acknowledgement. +A successful chunk acknowledgement can allow the strap to reclaim history. Local storage failure therefore withholds it. An end marker closes a chunk; transfer-complete closes the overall session. Timeouts do not authorize acknowledging an uncommitted open chunk. Cross-disconnect replay guarantees remain unknown. ABORT_HISTORICAL_TRANSMITS is a stop request, not a trim, and local cleanup must not depend on a reply arriving. FORCE_TRIM and SET_READ_POINTER are separate invasive cursor mutations and are not substitutes for normal chunk acknowledgement. -## Scheduled-control timing +### Scheduled-control timing The [high-frequency sync scheduler](PROTOCOL_COMMANDS.md#high-frequency-sync-scheduler) -uses wall-clock seconds for duration and a separate callback counter for its period. +compares wall-clock seconds for its duration, while its period is measured separately. It schedules events; it is not a demonstrated Bluetooth throughput control. Command 96/97 and events 96/97/98 occupy separate namespaces. @@ -113,20 +244,23 @@ Clock validity, stored configuration, command acknowledgement and eventual physi execution are distinct. Preserve the pending/final RUN response sequence and do not assume that manual RUN leaves the saved schedule intact. - -## Complete response bodies +### Complete response bodies Offsets in the following tables begin after command, origin sequence and result. Lengths exclude framing, CRC and padding. All integers are little-endian. Pending is not completion. -## Battery level — command 26 + + +#### Battery level — command 26 The final response body is **four bytes**, an unsigned whole-percent value. The fractional part is discarded. A successful response carries result 1; -a nonzero error on the ordinary completion callback carries result 0 and four zero bytes. Actual measurement timeout/error handling follows a different continuation and does not guarantee that reply. A zero value +an error reply carries result 0 and four zero bytes; a measurement timeout is not confirmed to produce that reply. A zero value can also be a conversion fallback, so it does not by itself prove a depleted battery. Do not interpret this version's logical body as a one-byte response. -## Hello — command 145 + + +#### Hello — command 145 Send revision 1 or 3. Initial PENDING has a zero-filled body except the revision: 107 bytes for revision 1, 111 for revision 3. Unsupported request revisions return @@ -157,18 +291,20 @@ applications generally need selected identity/version fields, not a raw body dum The final reply reports SUCCESS even if preparation status bits are set. Retain those bits and do not assume every field is valid merely because the result is 1. -Preparation bit `0x10` marks the failed identity-block-A helper condition, and -`0x20` marks the failed identity-block-B helper condition. Revision 3 can additionally -set `0x40` from its extra preparation check; that check's full meaning remains -unresolved. Other preparation bits must also be preserved. +Preparation bit `0x10` indicates that identity block A could not be filled, and +`0x20` indicates the same for identity block B. Revision 3 can additionally +set `0x40`; that bit's full meaning remains unresolved. Other preparation bits must also be preserved. Revision 3 adds the trailing word and an additional preparation-status check. The fractional field occupies four bytes despite the underlying clock fraction having only 16 significant bits. Text blocks must be bounded by their field width. -The legacy command 35 does not build a reply on its command handler path; do not +The legacy command 35 has no documented reply on 50.42.1.0; do not use it as an interchangeable command-145 request. -## Battery pack — command 151 + + +#### Battery pack — command 151 +This contract maps to the [LC709205F fuel gauge](PROTOCOL_WHOOP5.md#whoop5-lc709205f). Send revision 1. The immediate response reports cached information, with SUCCESS even when no pack is present. Its body is 28 bytes. Other request revisions return FAILURE with revision 1 and 27 zero bytes. @@ -185,7 +321,9 @@ FAILURE with revision 1 and 27 zero bytes. A successful response is not a live query or a freshness guarantee. -## Data range — command 34 + + +#### Data range — command 34 An empty PENDING body precedes completion. Final SUCCESS contains revision 1 followed by 16 unsigned 32-bit fields, totalling **65 bytes**. Failure, including @@ -210,7 +348,7 @@ the response timeout path, returns revision 1 followed by 64 zero bytes. For the derived quantity, C and D are each increased by capacity if below A. The record-count estimate is zero when adjusted trim and write positions coincide. On the successful record-sequence extraction path, it is the greater of the -unsigned 32-bit expression `currentSequence − extractedSequence + 1` and the page +unsigned 32-bit expression current sequence − extracted sequence + 1 and the page distance. Other paths use fallback estimates, so this is not an exact record count or an all-path formula. For accepted format-1 records, the first three clock pairs use the inner record @@ -221,20 +359,19 @@ stored components are zero. These fallbacks do not establish valid dates. The second member of each pair is a 16-bit value widened to 32 bits. Boundary A and the complete units of every retained clock form remain unresolved. Treat the estimates as counts/distances, not durations. Failed page reads produce seconds-like 0xffffffff and widened companion 65535; unsupported headers can produce zero pairs. The history contract below specifies local retries and current type-49 routing without a universal cross-version guarantee. - ### Historical synchronization: boundaries, retries and range interpretation -a history command response is an acceptance +On the wire, a history command response is an acceptance result, separate from the history stream. START opens the stream, END marks a consumer acknowledgement boundary, and COMPLETE closes the current attempt. Completion alone is insufficient to assert that every stored record has reached persistent application storage. Use saved progress and the available range to decide whether another bounded attempt is useful. -The current START, END and COMPLETE messages use metadata packet type 49. -This establishes the current implementation's particular history path, not a -universal firmware boundary between types 49 and 56. Keep support for other metadata -routes where independently required by supported-device evidence. +Observed traffic for this version uses metadata type 49 for START, END and +COMPLETE. This does not establish a universal boundary between types 49 and 56. +Keep support for other metadata routes where independently required by +supported-device observations. An END token contains eight bytes. Keep and echo the entire original token after persisting both the completed chunk and its local progress marker. The first word @@ -243,42 +380,39 @@ The ordinary acknowledgement path uses the first word for its boundary operation but this does not make the trailing bytes optional. Do not construct a replacement token from an independently saved cursor. -The ordinary boundary operation maps the supplied first word into the ring -geometry, handles boundary crossing and reduces the selected position modulo -capacity before updating the acknowledged/trim boundary. When write-wrap state -is zero, an ahead-of-write target is clamped to the write position. This does -not change the requirement to echo the original END token unchanged. +For an ordinary END token, the first word advances the acknowledged/trim boundary +within the ring capacity. Across a wrap, the resulting position remains within +that capacity; when write-wrap state is zero, a target ahead of the current write +position advances only to that write position. Echo the original END token unchanged. Special tokens are separate from ordinary chunk acknowledgements. With a first -word of `0xffffffff`, the storage handler skips the normal boundary operation but -still signals completion. A pair of `0xfefefefe` words enables a special mode and -arms its timer; the current write-page position replaces the normal boundary -input. A pair of `0xfdfdfdfd` words clears that mode and invokes a separate cursor-restoration path using a -stored history boundary before the subsequent boundary operation. That boundary -is distinct from the acknowledged/trim boundary; its complete retention role is -unresolved. While the special -mode is set, an ordinary pair also uses the current write position. These are -wire-reachable control cases, not replacement tokens to synthesize for a committed -chunk, nor a validated recovery or erase procedure. - -The selected history sender forwards positive, already-framed records only up to -2140 bytes. Zero is handled separately. This is a record-forwarding bound, not the -BLE MTU or a maximum for every protocol packet. - -The device has bounded local retries. Its END-wait timeout path resends END on -its first four expirations and changes state on the fifth. The repeated END may -have a new clock value while preserving its token. Non-success acknowledgements -use a separate counter and do not perform the normal successful boundary operation; -the fifth such outcome changes a local transfer limit. Neither branch provides an -exactly-once delivery promise across disconnects. The application should tolerate +word of `0xffffffff`, the strap signals completion without advancing the ordinary +boundary. A pair of `0xfefefefe` words selects a special mode in which the current +write-page position is used. A pair of `0xfdfdfdfd` words clears that mode and +restores a separately retained history boundary before the next boundary update. +The complete retention role of that second boundary is unresolved. While the +special mode remains selected, an ordinary pair also uses the current write +position. These are observable wire controls, not replacement tokens to synthesize +for a committed chunk, nor a validated recovery or erase procedure. + +History delivery emits positive-length, already-framed records only up to 2,140 +bytes. Zero is handled separately. This is a record-delivery bound, not the BLE +MTU or a maximum for every protocol packet. + +The device has bounded local retries. An unanswered END is resent up to four +times before the transfer leaves the END wait. A repeated END may carry a new +clock value while preserving its token. A non-success acknowledgement does not +perform the normal boundary operation, and the fifth such outcome ends the +attempt. Neither branch provides an exactly-once delivery promise across +disconnects. The application should tolerate repeated boundaries and records, retain arrival order, and stop an incomplete attempt without acknowledging data it could not persist. -The read cursor and the acknowledged/trim boundary are separate. A backing-page -read error can advance the read cursor and return an error. That observation does -not prove physical deletion or advancement of the acknowledged boundary. It does -mean the client must not assume that the very next read automatically retries the -same failed page. Preserve decode/read failures as part of synchronization evidence. +The read cursor and the acknowledged/trim boundary are separate. A page that +cannot be read is reported as an error and is not necessarily re-sent: the read +position can move past it. That does not prove physical deletion or advancement +of the acknowledged boundary. Preserve decode/read failures as part of +synchronization diagnostics. The extended range response contains page positions, capacity, estimates and four clock pairs. Read and write positions are page slots, not timestamps. The selected @@ -292,11 +426,11 @@ response. Keep sentinel and zero fallbacks separate from usable time values. ### Battery replies and cached accessory information -The normal battery request schedules a measurement before returning its percentage. -The physical measurement's error/timeout event takes a preparation continuation -that differs from the normal percentage callback. Therefore the ordinary callback's -failure-body layout does not establish a guaranteed battery reply after every -measurement timeout. Correlate the requested command and apply a bounded client wait. +The battery request returns a percentage that is measured for the request rather +than read from a cache. A measurement timeout is not confirmed to produce the +ordinary percentage reply, so the documented failure-body layout does not +establish a guaranteed battery reply after every timeout. Correlate the requested +command and apply a bounded client wait. Battery-pack information is cached. Both the full accessory record and its smaller charge update copy received values without local scale conversion. The strap-side message contract alone does not independently establish a physical percentage @@ -306,62 +440,50 @@ measurement or freshness. ### Interruption and recovery -a fresh history preparation restores the read position to the -acknowledged boundary when all stored ring positions are valid. Previously -received but unacknowledged records can therefore recur. Preparation failure can -also enter streaming, so START does not prove that restoration succeeded. +In this version, a fresh history attempt starts again from the acknowledged +boundary when the stored positions are valid. Previously received but +unacknowledged records can therefore recur. START can still appear when that +restoration did not succeed, so it is not proof of the restored position. -Connection loss exits history preparation, streaming or END wait and cancels their -transfer timers and subscriptions. Leaving history can itself queue COMPLETE when -the cached backlog is at most six; queueing proves neither delivery nor that all -source records were acknowledged. Reconcile completion metadata with persisted -progress and backlog. On the fifth END-wait timeout the attempt leaves history; -a later preparation performs the rewind, rather than that timeout immediately -restarting it. Earlier timeout retries resend END in the existing wait. +Connection loss ends preparation, streaming or END wait. COMPLETE can still be +emitted when the reported backlog is at most six; its appearance proves neither +delivery nor that all records were acknowledged. Reconcile completion metadata +with persisted progress and backlog. On the fifth END-wait timeout the attempt +ends; a later attempt can replay from the acknowledged boundary. Earlier timeouts +resend END while the transfer remains open. Persist data before ACK, retain the complete original token and tolerate duplicate -records. After reconnect, restore the application's subscriptions and reconcile -requested collection with actual output; automatic restoration of every sensor +records. After reconnect, enable the required notification channels again and +reconcile requested collection with actual output; restoration of every sensor request is not established. -Connection loss, a Bluetooth controller restart and full application startup are -distinct operations. Controller restart does not prove sensor session requests -were initialized again. On the successful application-start path, sensor -initialization clears temporary -collection requests and staged/live state. Later policy evaluation can reassert -persistent or continuous collection preferences. This startup initialization does -not establish that every reset command completes that path. +Connection loss, a Bluetooth controller restart and a full client restart are +distinct operations. None proves that previous temporary sensor requests were +restored. Re-establish temporary collection and live-output requests explicitly; +persistent or continuous preferences can remain separate. Temporary silence does not prove acquisition or history recording stopped. Track persistent collection preferences separately from temporary collection requests. Connection establishment is not an acknowledgement that ECG or sensor sessions were restored. Avoid blindly resending ECG start; [repeated starts](PROTOCOL_ECG.md#repeated-ecg-start-and-companion-collection) -can lose the bookkeeping used for companion collection cleanup. - -## Connection and error recovery boundaries - -Connection establishment is not a sensor-session restoration acknowledgement. -Selected connection handling cancels a connection-related timer and finishes -internal duration measurements; it does not establish that the app's previous -ECG or raw-sensor requests have been restored. - -Diagnostic records are best-effort evidence of lifecycle activity. A record can -be created and submitted internally but rejected later when the storage service's -queue has no free slot. Missing diagnostic records therefore do not prove that -an error or connection transition did not occur, and submission does not certify -durable storage. - -Error handling is conditional. The selected recovery policy distinguishes its -error reason from the ordinary link-loss reason, checks a matching occurrence -count, checks pending-state guards and stored bookkeeping, and schedules deferred -storage coordination. Do not treat every disconnect as an application reset or -as a request that clears all sensor sessions. Neither recovery scheduling nor a -new connection certifies acquisition state, completed reboot or durable storage. - -A guarded internal recovery sequence coordinates with storage and then enters a -fault-handling path after deferred steps. This sequence provides no application -acknowledgement guaranteeing that storage completed, a restart succeeded, or a -previous sensor session returned. It is not the generic behavior of ordinary -link loss. Clearing a related failure indicator does not, in the corresponding -handlers, cancel an already pending recovery sequence. +can lose the record used for companion collection cleanup. + +### Connection and error recovery boundaries + +Connection establishment is not a sensor-session restoration acknowledgement. It +does not establish that the app's previous ECG or raw-sensor requests have been +restored. + +Diagnostic records are best-effort signals of lifecycle activity and can be +dropped under load. Missing diagnostic records therefore do not prove that +an error or connection transition did not occur, and a recorded event does not +certify durable storage. + +Recovery behavior after a link error is not fully documented; do not assume a +disconnect resets sensor sessions. Error handling is conditional, and an ordinary +link loss is treated differently from other error reasons. Do not treat every +disconnect as an application reset or as a request that clears all sensor +sessions. Neither a recovery attempt nor a new connection certifies acquisition +state, completed reboot or durable storage, and no application acknowledgement +reports that a recovery finished. diff --git a/docs/PROTOCOL_UPDATES.md b/docs/PROTOCOL_UPDATES.md index b39af15849..8ac13f7449 100644 --- a/docs/PROTOCOL_UPDATES.md +++ b/docs/PROTOCOL_UPDATES.md @@ -2,7 +2,59 @@ This chapter follows the [shared scope](PROTOCOL.md#scope-and-compatibility). Image integrity, boot acceptance and session authorization are distinct mechanisms. These contracts describe boundaries, not a validated installation procedure. -## Image-transfer command boundaries +Neither generation has a documented end-to-end installation procedure in this +reference. Do not send update, lock, trim, reboot or power-cycle commands based on +these pages; no validated or authorized flashing path is documented. A CRC-valid +container does not prove a WHOOP signature, board compatibility, version ordering +or recoverability. + + + +## WHOOP 4 + +The documented WHOOP 4 package boundary is +`HARVARD`/`GEN_4`: MAXIM `41.17.6.0` plus bundled NORDIC `17.2.2.0`. It establishes +container and package facts, not a validated update or authorization interface. +Historical low-number image operations or shared numeric +IDs must not be mapped onto commands 142–159. Conversely, WHOOP 5/MG unsupported +status does not describe WHOOP 4. + +| Layer | Documented contract | What remains unproven | +|---|---|---| +| Outer package | ZIP contains one HARVARD MAXIM ZBIN and one BOYLSTON Nordic DFU ZIP | service eligibility, device selection and install order | +| MAXIM container | 512-byte header followed by gzip payload | complete header schema, signature fields and bootloader interpretation | +| MAXIM payload CRC | stored payload CRC32 equals CRC32 of compressed bytes | authenticity and device acceptance | +| MAXIM header CRC | stored header CRC32 equals CRC32 over header bytes `[8,0x1f8)` | purpose of every covered field and anti-tamper policy | +| MAXIM image | gzip expands to a 1,315,584-byte image for version `41.17.6.0` | flash placement, activation and successful boot | +| Nordic DFU | The [nRF52840 BLE processor](PROTOCOL_WHOOP4.md#whoop4-nrf52840) application is separate from combined SoftDevice/bootloader data; declared sizes are 153,140 SoftDevice bytes and 40,452 bootloader bytes | init-packet trust validation, compatibility and successful flash | +| Nordic DFU transition | command 45 starts the Nordic DFU transition; response, disconnect, later advertisement and usable DFU service are separate observations | exact phone request, retry timing and completed transition | + +WHOOP 4 supports START/LOAD/PROCESS/VERIFY operations at 36–38 and 83, with 85 +sharing the LOAD operation. The protocol distinguishes update-region erase, +indexed data and length, load success/failure, image CRC pass/fail, signature +verification and staged completion. These are not aliases for WHOOP +5/MG commands 142–144. Exact request lengths, transfer chunk limits, +cryptographic key and signed-range rules, authorization, +anti-rollback, boot acceptance, rollback and interruption recovery remain open. + +| WHOOP 4 command | 41.17.6.0 role | Contract boundary | +|---:|---|---| +| 36 | start load and erase update region | request revision/body and erase durability unresolved | +| 37, 85 | shared indexed firmware-data load operation | index and length are parsed; exact field widths/chunk maximum unresolved | +| 38 | process image and check image CRC | CRC pass is not authenticity or boot acceptance | +| 45 | request NORDIC DFU/bootloader mode | phone-visible response and successful Nordic transition unresolved | +| 83 | verify firmware image | algorithm, key and final trust decision unresolved | + + + +## WHOOP 5/MG + +Except for the separately labelled [WHOOP 4 boundary](#whoop-4), command +bodies, container fields and certificate rules in this chapter apply to **WHOOP +5/MG 50.42.1.0**. They are protocol contracts, not a validated installation or +flashing procedure. + +### Image-transfer command boundaries | Command | Request body | Response and limits | |---:|---|---| @@ -11,20 +63,20 @@ This chapter follows the [shared scope](PROTOCOL.md#scope-and-compatibility). Im | 143 | `revision:u8=1, offset:u32le, length:u8, data[length]` | Length at most 224. `[1, detail]`; success detail 0, otherwise a lower-layer error or preparation detail 10. Partition, alignment and storage constraints also apply. | | 144 | `revision:u8=1` | Image integrity acceptance returns `[1,1]` with result 1 and queues lifecycle work. Preparation, integrity or revision failure returns `[1,0]` with result 0. Later boot acceptance remains a separate boundary. | -An incomplete successful command 83 verification step schedules another step without -sending a final response. The normal continuation needs no additional client -command. Failure to close update storage does not replace the saved integrity +An incomplete successful command 83 verification step is followed by another step +without a final response in between. No additional client command is needed to +make verification proceed. Failure to close update storage does not replace the saved integrity result. Timeout, disconnection and overlapping requests remain unresolved; wait for the correlated result and do not treat silence as success. -Command 144 success confirms the application image CRC gate and requests storage -coordination plus a delayed board reset. Those requests do not establish completed -reset, installation, authenticity or boot acceptance. A reconnect does not by +Command 144 success confirms the application image CRC gate. A later disconnect, +reset or reconnect remains a separate observation and does not establish completed +installation, authenticity or boot acceptance. A reconnect does not by itself identify the accepted image. Signature, compression and rollback policies remain unspecified; CRC equality and version fields do not establish them. These contracts do not supply an installation sequence. -## Image container and integrity fields +### Image container and integrity fields The documented container format has a **512-byte header** followed by its payload. Offsets below are offsets within the file, not Bluetooth-frame or memory @@ -39,7 +91,7 @@ addresses. Multibyte numeric fields are little-endian. | 16 | 4 | Unresolved header word | | 504 | 4 | Header CRC32 over bytes 8–503 inclusive | -Both checks use the conventional CRC32 result format used by `zlib.crc32`. +Both checks use the conventional reflected CRC32 result. Unlisted header bytes include version/build information and unresolved fields; this table does not define them as zero or freely editable. The payload CRC and length are outside the header-CRC range. @@ -50,70 +102,14 @@ payload and type 1 carries the decompressed payload. This is a bounded type mapping, not a complete type enumeration. Do not substitute one representation's length or checksums for the other's. -The application checks partition bounds and payload integrity. Chunk writes of -the documented size are read back and compared; that comparison is separate from +Command 143 rejects chunks outside the accepted image range. Accepted chunk writes +are read back and compared; a mismatch fails the command. This is separate from whole-image verification and eventual boot acceptance. These fields support -container inspection; they do not establish which representation a complete +container validation; they do not establish which representation a complete installation procedure must transfer or that a modified container will boot. -## Certificate command boundaries +### Certificate command boundaries -| Command | Request body | Response and limits | -|---:|---|---| -| 155 | `revision:u8=1` | `[1]`; starts a fresh certificate transfer. | -| 156 | `revision:u8=1, offset:u16le, length:u8, data[length]` | `[1]`; chunks are at most 225 bytes and the transfer capacity is 2458 bytes. Use nonwrapping, in-range slices. An immediately repeated offset is acknowledged without comparing replacement content. | -| 157 | `revision:u8=1, declared_total:u16le` | `[1, detail]`: 1 validation success, 2 validation failure, 3 accumulated/declared length mismatch, 0 unsupported revision. Outer result is 1 only for validation success. | -| 158 | `revision:u8=1` | `[1]`; revalidates and processes the certificate. Result 1 reports processing success. Certificate and metadata storage are separate operations, so failure does not promise that persistent state is unchanged. | -| 159 | `revision:u8=1` | `[1]`; acknowledges a queued BLE authorization lock. Certificate clearing depends on the prior authorization state; eventual storage success is not reported. | - -Accumulated transfer length does not prove contiguous byte coverage. The certificate -has three nonempty, period-separated segments. Payload and signature use unpadded -URL-safe Base64. Verification covers the original encoded first and second -segments and their separating period; the uploaded payload does not supply the -trust key. Complete header-validation and issuer-provisioning policies remain -unspecified. - -Certificate verification uses SHA-256 with the P-256 signature -operation. The decoded signature is exactly 64 bytes: a 32-byte `r` followed by a -32-byte `s`, rather than an ASN.1 DER signature. Payload and signature decoding -each have a 512-byte output bound; the signature must also satisfy its exact -64-byte length. This algorithm contract does not establish complete cryptographic -implementation validation or firmware-image authentication and rollback policy. - -Required claims are strings `aud` and `sub`, bounded to 30 and 11 bytes and compared -to stored device identity fields, plus decimal unsigned 32-bit numeric `iat` and -`exp`, with `exp >= iat`. New transfers require `iat` to be strictly greater than the stored accepted value. If reading -that metadata fails, the comparison uses a zero-filled fallback instead; a read -failure does not itself force rejection. Identity-storage reads also initialize -buffers and continue to the identity comparisons after read errors; those errors -do not independently force certificate rejection. The separate remaining authorization duration is derived -from `exp - iat`; it is not the freshness value or an established direct wall-clock -comparison against `exp`. One accounting path charges elapsed monotonic seconds -during flash work, capped at the remaining amount. A successful write commits the -reduced amount and advances that accounting timestamp; failure behavior and reboot -restoration remain separate limits. A saved certificate is parsed during cold initialization -without requiring its -own saved `iat` to be newer than itself. That differs from accepting a new -transfer. JSON edge cases and complete duration restoration after reboot remain -unresolved. - -Command 158 revalidates, stores the certificate, updates accepted `iat`, requests -the remaining duration and queues an unlocked-state update. Storage operations -are separate and failure may follow a partial persistent change. Equal `exp` and -`iat` provide zero duration, so processing success does not guarantee a lasting -unlocked state. - -Command 159 queues an authorization lock. Applied to a previously unlocked state, -it also attempts to clear the stored certificate and requests zero duration; -already locked, it skips that clearing step. Its response does not report the -later storage result. This behavior does not establish irreversible fuse -programming. A later properly signed, identity-matching certificate that processes -successfully can request an unlocked state again; the persistent freshness -requirement still applies when its metadata is readable. This is not a tested -recovery procedure or a source of issuer authorization. - -A transfer acknowledgement, validation, storage, authorization update and lock -completion are distinct outcomes. No installation or lock/recovery operation has -been validated on a device for these contracts. +Commands 155 through 159 cover certificate transfer and device authorization. They gate an authorization state that requires a validly signed, identity-matching certificate; NOOP implements no update, authorization or unlock path, and the signing key and detailed validation rules are outside this reference. For sensor production, live/save policy, typed configuration and flag behavior use [configuration](PROTOCOL_CONFIGURATION.md); for ECG wrist/start/stop and independent raw/filtered routing use [ECG](PROTOCOL_ECG.md). Those pages separate requested state from applied state and packet delivery. Exact timing, energy cost, all reset paths and all hardware variants remain open unless a specific contract says otherwise. diff --git a/docs/PROTOCOL_WHOOP4.md b/docs/PROTOCOL_WHOOP4.md index 58caebd04d..33b034c897 100644 --- a/docs/PROTOCOL_WHOOP4.md +++ b/docs/PROTOCOL_WHOOP4.md @@ -2,23 +2,70 @@ Read the [scope and compatibility](PROTOCOL.md#scope-and-compatibility) before applying this page. -## WHOOP 4.0 — service `61080001-…` +## Version and validation boundary + +This profile combines version-labelled device observations with supported +interoperability behavior. The principal version boundary is MAXIM 41.17.6.0 plus NORDIC +17.2.2.0. A supported command is not automatically a complete request/response +contract or a validated physical effect. A command or field documented only for +WHOOP 5/MG is not inherited. + +This profile aims for the same **topic coverage** as the WHOOP 5/MG handbook: +transport, commands, records, configuration, updates, validation and open gaps all +have an explicit home. That is documentation parity, not a claim of equal wire or +runtime depth. WHOOP 4 sections remain shallower wherever only supported behavior or a +small set of device observations exists, and every such boundary stays +visible instead of being filled from WHOOP 5/MG. + +Known record diversity is material: the documented layouts include legacy type-47 +layouts v5/7/9/12 and v24. Version 41.17.6.0 produces v24 by default and can also +produce the distinct 84-byte v25 layout, depending on device configuration. Neither layout is +universal. Select by the emitted record version and validated length, +never by the marketing generation alone. + +| Topic | WHOOP 4 authority | +|---|---| +| GATT, envelope, response offsets | This page and [transport](PROTOCOL_TRANSPORT.md#whoop-4) | +| Commands | [Comparative matrix](PROTOCOL_COMMANDS.md#canonical-command-matrix) and [WHOOP 4 contracts](PROTOCOL_COMMANDS.md#whoop-4) | +| History and battery | [WHOOP 4 transport profile](PROTOCOL_TRANSPORT.md#whoop-4) | +| Measurements | [WHOOP 4 sensor records](PROTOCOL_SENSORS.md#whoop-4) | +| Configuration and updates | Explicit generation boundaries in [configuration](PROTOCOL_CONFIGURATION.md#whoop-4) and [updates](PROTOCOL_UPDATES.md#whoop-4) | +| Alarms and haptics | [Alarms](PROTOCOL_ALARMS.md#whoop-4) | +| Shared concepts | [Shared concepts](PROTOCOL_CONCEPTS.md) | + +## Hardware overview + +The following components are documented for the 41.17.6.0 / 17.2.2.0 package. +Part identities explain which protocol contracts exist; they do not imply +calibration or physical validation. + +| Function | Component | Protocol relationship | +|---|---|---| +| Bluetooth LE processor | Nordic nRF52840 (application plus SoftDevice/bootloader, updated as a DFU package) | Exposes the `61080001-…` service and the standard Heart Rate and Battery services; the update container carries a separate NORDIC image. | + +WHOOP 4 splits Bluetooth work and application work across separate processors. +The path between them is not visible at the BLE interface and remains outside +this reference, as described in +[transport](PROTOCOL_TRANSPORT.md#whoop-4-transport-profile). + + -Defined in `BLEManager.swift` (the on-device, authoritative UUIDs) and mirrored as plain -strings in `DeviceFamily.swift`. The same `Strand/BLE/` sources (`BLEManager`, -`StandardHeartRate`, `FrameRouter`) back both Apple-platform targets — macOS and iOS. +## WHOOP 4 — service `61080001-…` + +The UUIDs below are the values the strap exposes. | Role | UUID | Direction | |------|------|-----------| | Custom service | `61080001-8d6d-82b8-614a-1c8cb0f8dcc6` | — | -| Command write (`cmdWriteChar`) | `61080002-8d6d-82b8-614a-1c8cb0f8dcc6` | app → strap | -| Command-response notify (`cmdNotifyChar`) | `61080003-8d6d-82b8-614a-1c8cb0f8dcc6` | strap → app | -| Event notify (`eventNotifyChar`) | `61080004-8d6d-82b8-614a-1c8cb0f8dcc6` | strap → app | -| Data notify (`dataNotifyChar`, fragmented) | `61080005-8d6d-82b8-614a-1c8cb0f8dcc6` | strap → app | +| Command write | `61080002-8d6d-82b8-614a-1c8cb0f8dcc6` | app → strap | +| Command-response notify | `61080003-8d6d-82b8-614a-1c8cb0f8dcc6` | strap → app | +| Event notify | `61080004-8d6d-82b8-614a-1c8cb0f8dcc6` | strap → app | +| Data notify (fragmented) | `61080005-8d6d-82b8-614a-1c8cb0f8dcc6` | strap → app | + -## WHOOP 4.0 envelope +## WHOOP 4 envelope ``` ┌──────┬───────────────┬───────┬───────────── inner ─────────────┬─────────────┐ @@ -32,127 +79,136 @@ total frame size = length + 4 - **`length`** — `u16` little-endian. Equals `inner.count + 4` (the inner `[type][seq][cmd] payload]` plus the 4 envelope bytes). It is the offset at which the CRC32 trailer begins. - **`crc8`** — CRC8 (table-driven, poly `0x07`) computed over the **two length bytes only** - (`crc8([frame[1], frame[2]])`). + (`frame[1]` and `frame[2]`). - **inner record** — `type` (packet type), `seq` (sequence / version byte), `cmd` (command number), then the payload. - **`crc32`** — standard zlib CRC-32 (reflected, poly `0xEDB88320`), `u32` little-endian, computed over the **inner bytes** `frame[4 .. length)`. -Reference: `verifyFrame(_:)` and `crc8(_:)` / `crc32(_:)` in `Framing.swift`, and the -outbound builder `WhoopCommand.frame(seq:payload:)` in `Strand/BLE/Commands.swift`. - -```swift -// Framing.swift — WHOOP 4.0 validation (abridged) -let length = u16le(frame, 1) -let crc8OK = crc8([frame[1], frame[2]]) == frame[3] -if 7 <= length && length + 4 <= frame.count { - let inner = Array(frame[4.. + -## Bond handshake & connect lifecycle (WHOOP 4.0) +## Bond handshake & connect lifecycle (WHOOP 4) -This section records NOOP’s WHOOP 4 connection sequence and its observations. The delays and periodic timers are client policy, not required protocol timing. NOOP marks its WHOOP 4 connection as bonded after the confirmed command write is -acknowledged, then runs its connection handshake. This describes client connection -handling; a write acknowledgement is not independent proof of a persistent OS bond. +A working connection sequence for WHOOP 4 is below. A write acknowledgement is not +independent proof of a persistent OS bond. In the observed sequence, command traffic +becomes usable after one confirmed write is acknowledged without error. ``` -scan(service 61080001) ─▶ connect ─▶ discoverServices - └▶ discoverCharacteristics - ├ on cmdWriteChar (0002): - │ confirmed write GET_BATTERY_LEVEL ── THE BOND TRICK - └ on 0003/0004/0005/2A37/2A19: setNotifyValue(true) - confirmed-write ack (didWriteValueFor, no error) ─▶ BONDED (state.bonded = true) +scan(service 61080001) ─▶ connect ─▶ discover services + └▶ discover characteristics + ├ confirmed GET_BATTERY_LEVEL write on 0002 + └ notification subscriptions on 0003/0004/0005/2A37/2A19 + confirmed-write acknowledgement without error ─▶ link usable for commands ``` -After bonding, the connect handshake runs **exactly once** per connection (guarded by -`connectHandshakeDone`, because `didWriteValueFor` re-fires on every later `.withResponse` -write). Re-blasting the handshake mid-offload was the historical root cause of the strap -refusing to stream type-47, so the guard is load-bearing. The one-shot handshake (in -`peripheral(_:didWriteValueFor:error:)`) issues, in order: +The ordered command sequence that follows is: -1. `GET_HELLO_HARVARD` (35) — version/identity hello (mirrors the official flow; not strictly - required to serve). +1. `GET_HELLO_HARVARD` (35) — version/identity hello. 2. `GET_ADVERTISING_NAME_HARVARD` (76). -3. `SET_CLOCK` (10) — the client sends both retained variants with the same Unix - seconds: four seconds bytes followed by four zeros, then four seconds bytes - followed by five zeros. These are WHOOP 4 compatibility attempts. -4. `GET_CLOCK` (11) — the client tries both an empty body and `00`. Which form - responds or updates the clock depends on the supported WHOOP 4 firmware. - Earlier investigations reported unsuitable bodies leaving the clock unchanged, - including cases with an acknowledgement. Read back the clock: ACK alone does - not prove it latched, and silent history does not uniquely identify a clock problem. -5. `SEND_R10_R11_REALTIME` (63) with `[0x00]` — stop the ~2/s type-43 raw flood (BLE airtime / - battery / flash). This is the *real* control for that stream; `STOP_RAW_DATA` (82) does not +3. `REPORT_VERSION_INFO` (7) — reads the documented Harvard and Boylston version + components from its 68-byte response body. +4. `SET_CLOCK` (10) — the eight- and nine-byte request forms are outside the + documented 41.17.6.0 command set. On some devices one of the two forms was + observed to latch; read back to confirm. +5. `GET_CLOCK` (11) — the empty and `00` request forms are outside the documented + 41.17.6.0 command set. Use the form accepted by the device for readback. +6. `SEND_R10_R11_REALTIME` (63) with `[0x00]` — stops the roughly 2/s type-43 raw + flood. This is the control for that stream; `STOP_RAW_DATA` (82) does not affect it. -6. `GET_DATA_RANGE` (34) — refresh the strap's stored record range for the liveness watchdog. -7. After ~1.5 s (so the link settles), the first historical offload via `requestSync(.connect)`. +7. `GET_DATA_RANGE` (34) — reads the strap's stored record range. +8. The first historical offload, after the link has settled. -A periodic backfill timer (`backfillIntervalSeconds = 900`, i.e. 15 min, matching WHOOP) and a -keep-alive timer (`keepAliveIntervalSeconds = 30`: re-arm realtime, poll battery, watchdog the -link) are then started. The `GET_CLOCK` response is decoded by `ClockCorrelation` to produce a -`ClockRef(device:wall:)` for realtime decoding. Backfill can also proceed with -the client's identity-clock fallback when correlation is unavailable. +**Observed in device captures:** re-running this sequence in the middle of an +offload stopped type-47 streaming. Run it once per connection. Scheduling around +the sequence is application policy, not required protocol timing; the NOOP policy +is recorded on the [implementation page](PROTOCOL_IMPLEMENTATION.md#noop-connection-policy). -> WHOOP 5.0 instead writes the static `CLIENT_HELLO` [frame](PROTOCOL_WHOOP5.md#connection-and-frame-format) to its `…0002` command +> WHOOP 5/MG instead writes the fixed `CLIENT_HELLO` [frame](PROTOCOL_WHOOP5.md#connection-and-frame-format) to its `…0002` command > characteristic immediately after discovery. +[Next: WHOOP 4 commands](PROTOCOL_COMMANDS.md#whoop-4) + --- ## `HISTORY_END` payload layout -The following offset table is the historical WHOOP 4 decoder convention. WHOOP 5/MG has a separate [history contract](PROTOCOL_TRANSPORT.md#historical-synchronization-boundaries-retries-and-range-interpretation); do not reuse these offsets for it. +The following offset table is the documented layout for this WHOOP 4 version. +WHOOP 5/MG has a separate [history contract](PROTOCOL_TRANSPORT.md#historical-synchronization-boundaries-retries-and-range-interpretation); +do not reuse these offsets for it. -The `metadata` post-hook decodes the payload (which begins at `frame[7]`, after `[type][seq] -[cmd]`) as `struct ' -## `GET_HELLO_HARVARD` (35) response — the WHOOP 4.0 serial +## `GET_HELLO_HARVARD` (35) response — the WHOOP 4 serial -A 4.0 exposes no DIS Serial Number String (`0x2A25`), so this response is the only place its stable +A WHOOP 4 exposes no DIS Serial Number String (`0x2A25`), so this response is the only place its stable serial appears. In the captures on record the response payload (sliced past `SOF+len+crc8` and `[type,seq,cmd,origin_seq,result]`, i.e. from byte 9 of the frame) is **131 bytes** and carries two alphanumeric runs: | payload offset | length | what | |---|---|---| -| 14 | 9 | **strap serial** — the stable per-device id (`Whoop4HelloSerial`) | -| 24 | 54 | **device key** — a secret; never read it, never log it, never let it become an id | +| 14 | 10 | **strap serial field** — nine serial bytes plus a terminating NUL | +| 24 | 54 | **key and signature material** — sensitive; never log it or let it become an id | -`Whoop4HelloSerial` reads a FIXED 9-byte window at offset 14 for exactly this reason: a scanning -"longest alnum run" could drift onto the key as payloads vary, and a fixed window cannot. +The serial content occupies nine bytes in the fixed ten-byte field at payload +offset 14; the tenth byte is NUL. Treating an arbitrary alphanumeric run as +identity could instead expose the adjacent sensitive region. -**Provenance, because it changes how much this should be trusted:** the offsets come from a single -capture, not from documentation. They are corroborated only in the sense that two independent places in -the codebase record the same layout — which is one observation written down twice, not two -observations. Treat a strap that stops adopting as evidence the field moved, rather than assuming the -table is wrong about the shape. This is why the 4.0 adoption path waits for the same value on two -separate hellos before acting on it (`RepeatedSerialGate`), where a 5/MG adopts its spec-defined DIS -serial on first read. +**Documented for this version; observed in device captures.** If another version +does not expose a plausible nine-byte serial at this offset, leave identity +unresolved rather than scanning into the sensitive region. ## Commands and records -The [historical sender inventory](PROTOCOL_IMPLEMENTATION.md#6-commandnumber-sending--the-safe-subset) records WHOOP 4 payload conventions. It is not equivalent to the WHOOP 5/MG catalog. WHOOP 4 battery replies use the legacy `u16le / 10` percent convention. Historical layouts are selected by their version in NOOP’s schema; the v24 optical/respiration fields must not be mapped onto WHOOP 5 records by adding four to offsets. See [decoder notes](PROTOCOL_IMPLEMENTATION.md#8-decoded-output-parsedframe) and the [historical measurement discussion](WHOOP5_DEEP_DATA.md#spo₂-and-respiration-interpretation-limits). - -## Legacy IMU client-schema layout - -NOOP's WHOOP 4 schema selects an IMU variant by declared length 1,917, with 100 signed `i16le` -values per axis. Absolute frame offsets are 89/289/489 for acceleration X/Y/Z -and 692/892/1092 for gyro X/Y/Z. The client applies acceleration scale `1/4096` -and gyro scale `2000/32768`; these are legacy decoder conventions and do not -establish WHOOP 5/MG scaling. See the -[bundled schema](../Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json). +The [canonical matrix](PROTOCOL_COMMANDS.md#canonical-command-matrix) contains +every ID 1–159 exactly once and distinguishes supported, observed, partial, +unsupported and unknown states per generation. Its +[WHOOP 4 contracts](PROTOCOL_COMMANDS.md#whoop-4) retain exact +observed requests and compatibility knowledge. The 41.17.6.0 classification covers +IDs 1–132: 85 matrix entries are `S` and 47 are `U`. The remaining +27 IDs in the complete matrix are outside that version-specific range. + +Version differences remain explicit. In particular, clock and history forms +outside the documented 41.17.6.0 command set remain separately labelled even +when device captures show working behavior. Commands 20, 22 and 23 were observed +working on that version, including type-47 delivery and `HISTORY_END` +acknowledgement. On some devices one of the two SET_CLOCK forms was observed to +latch; read back to confirm. + +The complete matrix is broader than any application's transmission subset. WHOOP 4 battery replies +use the legacy `u16le / 10` percent convention. Historical record versions have +distinct layouts; the v24 optical/respiration fields must not be mapped onto +WHOOP 5 records by adding four to offsets. See the +[historical measurement discussion](WHOOP5_DEEP_DATA.md#spo₂-and-respiration-interpretation-limits). + + + +## Legacy IMU layout + +The documented layout for this version has payload length 1,917, corresponding to +declared length 1,924. It contains 100 signed `i16le` values per axis. Absolute +frame offsets are 89/289/489 for acceleration X/Y/Z and 692/892/1092 for gyro +X/Y/Z. Acceleration scale `1/4096` and gyro scale `2000/32768` are commonly +applied interpretations, not confirmed device parameters or WHOOP 5/MG scaling. diff --git a/docs/PROTOCOL_WHOOP5.md b/docs/PROTOCOL_WHOOP5.md index 033498bb3b..9539be1945 100644 --- a/docs/PROTOCOL_WHOOP5.md +++ b/docs/PROTOCOL_WHOOP5.md @@ -1,11 +1,51 @@ -# WHOOP 5 / MG profile +# WHOOP 5/MG profile + + Read the [scope and compatibility](PROTOCOL.md#scope-and-compatibility) before applying this page. -## WHOOP 5.0 / MG — service `fd4b0001-…` +## Version and validation boundary + +Unless a paragraph names an older observation, app version or firmware, the linked +command, transport, configuration, sensor and update contracts apply to 50.42.1.0. +They do not imply validation on every device state. Application-specific parsing, +timing and feature gating are documented separately, and older observations +remain bound to their stated firmware. No field or +operation on these pages should be backported to WHOOP 4 by subtracting an envelope +offset or substituting a low-number opcode. + +| Topic | WHOOP 5/MG authority | +|---|---| +| GATT and connection hello | This page | +| Framing, replies, history and identity | [Transport](PROTOCOL_TRANSPORT.md#whoop-5mg) | +| Commands | [Comparative matrix](PROTOCOL_COMMANDS.md#canonical-command-matrix) and [WHOOP 5/MG contracts](PROTOCOL_COMMANDS.md#whoop-5mg) | +| Measurements and collection | [Sensors](PROTOCOL_SENSORS.md#whoop-5mg) and [configuration](PROTOCOL_CONFIGURATION.md#whoop-5mg) | +| Image and authorization interfaces | [Updates](PROTOCOL_UPDATES.md#whoop-5mg) | +| Alarms and haptics | [Alarms](PROTOCOL_ALARMS.md#whoop-5mg) | +| Shared concepts | [Shared concepts](PROTOCOL_CONCEPTS.md) | + +## Hardware overview + +The following components are documented for firmware 50.42.1.0. Part identities +explain which protocol contracts exist; they do not imply calibration or physical +validation. + +| Function | Component | Protocol relationship | +|---|---|---| +| Optical front end and ECG front end | Analog Devices MAX86176 | AFE parameters (commands 61/62), optical records R20/R26, ECG records R16/R17 on MG; absolute ECG sample rate and volts per count are not documented. | +| 6-axis IMU with pedometer | TDK InvenSense ICM-45686 | IMU records R21 and stream types 51/52, gyro mode (150/152), step counters. | +| Haptics driver | Texas Instruments DRV2625 | Alarm and haptic pattern commands. | +| Battery fuel gauge | onsemi LC709205F | Battery pack information (command 151), battery level (26). | +| Skin temperature sensor | ams OSRAM AS6221 | Temperature fields in biometric/history records. | + +MG-only ECG depends on the MAX86176 ECG channel and the ECG-conductive clasp; +WHOOP 5.0 and MG otherwise share the sensor components listed here. + + + +## WHOOP 5/MG — service `fd4b0001-…` -The 5.0 transport ("puffin") adds a fifth characteristic (`…0007`). UUID strings are in -`DeviceFamily.characteristicUUIDStrings`. +The WHOOP 5/MG service exposes five characteristics, one more than the WHOOP 4 service. | Role | UUID | |------|------| @@ -13,38 +53,42 @@ The 5.0 transport ("puffin") adds a fifth characteristic (`…0007`). UUID strin | Command write | `fd4b0002-cce1-4033-93ce-002d5875f58a` | | Notify channels | `fd4b0003`, `fd4b0004`, `fd4b0005`, `fd4b0007` (`…-cce1-4033-93ce-002d5875f58a`) | -NOOP's historical "puffin" label refers to this fd4b Maverick/Goose framing. Decompiled WHOOP app -taxonomy also names a separate `PUFFIN` service family at -`11500001-6215-11ee-8c99-0242ac120002`; NOOP names that metadata `puffin1150` to avoid confusing it -with the implemented fd4b path. +The `fd4b` service carries the Maverick/Goose framing documented here. A separate +service family is identified at `11500001-6215-11ee-8c99-0242ac120002`; its +framing and supported operations remain unresolved. ## WHOOP 5.0 vs MG — telling the hardware apart -Both labels share the `fd4b…` GATT family and the same puffin envelope: the shared framing and parser family is represented by `DeviceFamily.whoop5`. Hardware capabilities and record availability still need to be checked separately. What differs is hardware — +Both labels share the `fd4b…` GATT family and the same puffin envelope, so both are +framed and parsed as one family. Hardware capabilities and record availability still need to be checked separately. What differs is hardware — an MG carries the ECG-conductive clasp, a 5.0 does not. -NOOP's `Whoop5Variant` resolver uses the standard BLE Device Information Service, -separately from `DeviceFamily`. Its current identification policy is listed below; -these matching rules do not change frame parsing. +The standard BLE Device Information Service can expose model, serial and hardware +revision signals associated with the two variants. These observations do not +change frame parsing or establish a universal identification rule. | Signal | DIS characteristic | Reads | |---|---|---| -| Model number `MG` (case-insensitive, surrounding whitespace ignored) | Model Number String (`0x2A24`) | MG; takes priority over the fallback signals below | +| Model number `MG` | Model Number String (`0x2A24`) | MG | | Serial prefix `5AM` | Serial Number String (`0x2A25`) | MG | | Serial prefix `5AG` | Serial Number String (`0x2A25`) | 5.0 | | Hardware revision contains `WG50` | Hardware Revision String (`0x2A27`) | 5.0 | -Without an explicit `MG` model number, conflicting `5AM` serial and `WG50` hardware -signals resolve to `.unknown`. Unknown variants do not enable MG-only features. +Conflicting `5AM` serial and `WG50` hardware signals leave the variant unresolved. +An unresolved variant does not establish MG-only capability. An MG has also been observed with a different serial prefix and hardware revision; -there is no universal MG hardware-revision token in this resolver. Absence of a +there is no universal MG hardware-revision token. Absence of a recognized prefix therefore does not establish that the strap lacks MG hardware. ## Connection and frame format -Subscribe to the fd4b notification channels and write the static client hello below to the command characteristic with response. WHOOP 4’s bond/hello sequence is a separate profile. Framing, padding, response correlation and recovery are defined once in [transport](PROTOCOL_TRANSPORT.md). +Use the [Format 1 envelope](PROTOCOL_TRANSPORT.md#format-1-framing), including its +padding and response-correlation rules, before sending the Hello step. Subscribe +to the fd4b notification channels, then write the fixed client hello below to the +command characteristic with response. WHOOP 4's bond/hello sequence is a separate +profile; recovery is defined in [transport](PROTOCOL_TRANSPORT.md). ```text AA 01 08 00 00 01 E6 71 23 01 91 01 36 3E 5C 8D diff --git a/docs/protocol-examples/check_source_references.py b/docs/protocol-examples/check_source_references.py new file mode 100755 index 0000000000..cb2239e567 --- /dev/null +++ b/docs/protocol-examples/check_source_references.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Check source references in protocol implementation documentation. + +Format: use ``[Symbol](../relative/source.swift)`` for code symbols. +Format: the link text may use ``Type.member(args:)``; the final name is checked. +Format: use backticks for file-only paths; those are checked for existence only. +Run: ``python3 docs/protocol-examples/check_source_references.py [docs ...]``. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_DOC = REPO_ROOT / "docs" / "PROTOCOL_IMPLEMENTATION.md" +LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") +SOURCE_LINK_SUFFIXES = {".swift", ".kt", ".json", ".py", ".yml"} +BACKTICK_PATH_RE = re.compile( + r"`([^`\n]*?/[^`\n]+\.(?:swift|kt|json|py))`", re.IGNORECASE +) +SYMBOL_TEXT_RE = re.compile( + r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*" + r"(?:\([^\n)]*\))?$" +) +IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +def display_path(path: Path) -> str: + try: + return path.relative_to(REPO_ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def line_number(text: str, offset: int) -> int: + return text.count("\n", 0, offset) + 1 + + +def symbol_from_text(link_text: str) -> str | None: + text = link_text.strip() + if not SYMBOL_TEXT_RE.fullmatch(text): + return None + before_args = text.split("(", 1)[0] + identifiers = IDENTIFIER_RE.findall(before_args) + return identifiers[-1] if identifiers else None + + +def resolve_target(doc: Path, raw_target: str) -> tuple[Path, str] | None: + target = raw_target.split("#", 1)[0] + if not target or target.startswith(("/", "http://", "https://", "mailto:")): + return None + return (doc.parent / target).resolve(), target + + +def check_doc(doc: Path) -> tuple[int, set[Path], list[str]]: + text = doc.read_text(encoding="utf-8") + checked = 0 + files: set[Path] = set() + failures: list[str] = [] + doc_name = display_path(doc) + + for match in LINK_RE.finditer(text): + link_text, raw_target = match.groups() + resolved = resolve_target(doc, raw_target) + if resolved is None: + continue + target, target_text = resolved + if target.suffix.lower() not in SOURCE_LINK_SUFFIXES: + continue + checked += 1 + files.add(target) + line = line_number(text, match.start()) + symbol = symbol_from_text(link_text) + symbol_field = f" [{symbol}]" if symbol else "" + if not target.is_file(): + failures.append( + f"FAIL {doc_name}:{line} {target_text}{symbol_field} file does not exist" + ) + continue + if symbol: + source = target.read_text(encoding="utf-8", errors="replace") + if re.search(rf"\b{re.escape(symbol)}\b", source) is None: + failures.append( + f"FAIL {doc_name}:{line} {target_text} [{symbol}] symbol not found" + ) + + occupied = [(match.start(), match.end()) for match in LINK_RE.finditer(text)] + for match in BACKTICK_PATH_RE.finditer(text): + if any(start <= match.start() < end for start, end in occupied): + continue + raw_target = match.group(1) + resolved = resolve_target(doc, raw_target) + if resolved is None: + continue + target, target_text = resolved + checked += 1 + files.add(target) + line = line_number(text, match.start()) + if not target.is_file(): + failures.append( + f"FAIL {doc_name}:{line} {target_text} file does not exist" + ) + + return checked, files, failures + + +def main(argv: list[str]) -> int: + docs = [Path(arg).resolve() for arg in argv] if argv else [DEFAULT_DOC] + checked = 0 + files: set[Path] = set() + failures: list[str] = [] + + for doc in docs: + if not doc.is_file(): + failures.append( + f"FAIL {display_path(doc)}:1 {display_path(doc)} document does not exist" + ) + continue + doc_checked, doc_files, doc_failures = check_doc(doc) + checked += doc_checked + files.update(doc_files) + failures.extend(doc_failures) + + for failure in failures: + print(failure) + print(f"checked {checked} references, {len(files)} files, {len(failures)} failures") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))