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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/source-hygiene.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 3 additions & 3 deletions .github/workflows/tools-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
143 changes: 143 additions & 0 deletions Tools/test_check_source_references.py
Original file line number Diff line number Diff line change
@@ -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()
121 changes: 67 additions & 54 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<a id="whoop-40--service-61080001-"></a>
<a id="whoop-50--mg--service-fd4b0001-"></a>
<a id="21-whoop-40-envelope"></a>
<a id="22-whoop-50--mg-envelope"></a>
<a id="5-bond-handshake--connect-lifecycle-whoop-40"></a>
<a id="9-whoop-50-vs-mg--telling-the-hardware-apart"></a>
<a id="get_hello_harvard-35-response--the-whoop-40-serial"></a>
Documentation coverage is broader than any one implementation's command and record subset.

## Scope and compatibility

Expand All @@ -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.

<a id="extended-protocol-reference"></a>
<a id="1-gatt-topology"></a>
<a id="diagnostic-only-whoop-service-families"></a>
<a id="standard-sig-services-both-generations"></a>
<a id="2-frame-envelope"></a>
<a id="23-family-aware-entry-points"></a>
<a id="24-command_response-body"></a>
<a id="25-checksums"></a>
<a id="26-reassembly"></a>
<a id="3-packettype-offset-4-or-8-on-50"></a>
<a id="4-eventnumber-event-type-48"></a>
<a id="6-commandnumber-sending--the-safe-subset"></a>
<a id="additional-5-class-command-numbers"></a>
<a id="destructive-commands--do-not-send"></a>
<a id="7-historical-data-offload-backfill"></a>
<a id="71-metadatatype-metadata6"></a>
<a id="72-history_end-payload-layout"></a>
<a id="73-session-state-machine"></a>
<a id="74-safe-trim-invariant"></a>
<a id="75-watchdog--liveness"></a>
<a id="8-decoded-output-parsedframe"></a>
<a id="91-ecg-labrador-on-the-mg"></a>
<a id="10-spo₂-on-50--mg--what-the-wire-does-and-does-not-carry"></a>
<a id="companion-reference-corrections--504210"></a>
<a id="previous-section-links"></a>
“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
Expand All @@ -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.

<a id="11-file-map"></a>

## 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.

<a id="whoop-40--service-61080001-"></a>
<a id="whoop-50--mg--service-fd4b0001-"></a>
<a id="21-whoop-40-envelope"></a>
<a id="22-whoop-50--mg-envelope"></a>
<a id="5-bond-handshake--connect-lifecycle-whoop-40"></a>
<a id="9-whoop-50-vs-mg--telling-the-hardware-apart"></a>
<a id="get_hello_harvard-35-response--the-whoop-40-serial"></a>
<a id="extended-protocol-reference"></a>
<a id="1-gatt-topology"></a>
<a id="diagnostic-only-whoop-service-families"></a>
<a id="standard-sig-services-both-generations"></a>
<a id="2-frame-envelope"></a>
<a id="23-family-aware-entry-points"></a>
<a id="24-command_response-body"></a>
<a id="25-checksums"></a>
<a id="26-reassembly"></a>
<a id="3-packettype-offset-4-or-8-on-50"></a>
<a id="4-eventnumber-event-type-48"></a>
<a id="6-commandnumber-sending--the-safe-subset"></a>
<a id="additional-5-class-command-numbers"></a>
<a id="destructive-commands--do-not-send"></a>
<a id="7-historical-data-offload-backfill"></a>
<a id="71-metadatatype-metadata6"></a>
<a id="72-history_end-payload-layout"></a>
<a id="73-session-state-machine"></a>
<a id="74-safe-trim-invariant"></a>
<a id="75-watchdog--liveness"></a>
<a id="8-decoded-output-parsedframe"></a>
<a id="91-ecg-labrador-on-the-mg"></a>
<a id="10-spo₂-on-50--mg--what-the-wire-does-and-does-not-carry"></a>
<a id="companion-reference-corrections--504210"></a>
<a id="previous-section-links"></a>
<a id="11-file-map"></a>
Loading
Loading