From 07b3fc0ca76ed6a27c8297cbe3dc08dc829958bd Mon Sep 17 00:00:00 2001 From: ryanbr Date: Fri, 31 Jul 2026 18:38:01 -0700 Subject: [PATCH 1/3] Normalise tools/ into Tools/ so the two trees stop colliding on case (#979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo carried Tools/ (50 files) and tools/ (27, all of them linux-capture) as distinct trees. On a case-insensitive checkout — macOS, which is the reference platform and what CI runs Swift on — they merge into one visible directory, so a contributor can add a file to what they see as Tools/linux-capture/ and have git record it under the other tree. Nothing was broken: there are no colliding paths between the two, checked rather than assumed. The reason to fix it anyway is what the collision can silently defeat. tools-python.yml exists because 212 linux-capture tests were only run when someone remembered to; it is path-filtered on tools/**. A file landing in the wrong tree does not match that filter, so a new suite would go unrun — the exact failure mode that workflow was written to end. tools/linux-capture -> Tools/linux-capture, with 22 references rewritten across docs, workflows and four doc comments, plus the directory diagram in CONTRIBUTING.md, which had been drawing the two trees as siblings. The path filter becomes Tools/linux-capture/** rather than Tools/**, which preserves the trigger exactly: lowercase tools/ only ever contained linux-capture, so Tools/** would newly fire this job on all 50 unrelated Tools/ files. Checked that the move cannot be swallowed by the sibling 'Run Tools/ tests' step, which discovers from Tools/ with a 45-test floor: unittest discovery does not recurse into a directory without __init__.py, verified by running it both ways rather than trusting the rule. linux-capture has none. Both suites run from their new homes: 212 tests (floor 200) and 53 (floor 45, unchanged — not 265, so no absorption). --- .github/workflows/tools-python.yml | 14 +++++++------- CHANGELOG.md | 2 +- .../Sources/WhoopProtocol/Interpreter.swift | 2 +- .../Sources/WhoopProtocol/PpgHr.swift | 2 +- .../Whoop5PpgWaveformTests.swift | 2 +- {tools => Tools}/linux-capture/.gitignore | 0 {tools => Tools}/linux-capture/README.md | 2 +- .../linux-capture/analyze_command_responses.py | 0 .../linux-capture/analyze_v25_waveform.py | 0 .../linux-capture/analyze_v26_waveform.py | 0 .../linux-capture/correlate_ground_truth.py | 0 {tools => Tools}/linux-capture/decode_features.py | 0 {tools => Tools}/linux-capture/hci_extract.py | 0 {tools => Tools}/linux-capture/pair_probe.py | 0 {tools => Tools}/linux-capture/requirements.txt | 0 .../linux-capture/test_correlate_ground_truth.py | 0 .../linux-capture/test_decode_features.py | 0 {tools => Tools}/linux-capture/test_hci_extract.py | 0 .../linux-capture/test_validate_spo2_candidate.py | 0 .../linux-capture/test_whoop_activity.py | 0 {tools => Tools}/linux-capture/test_whoop_frame.py | 0 .../linux-capture/test_whoop_spot_hrv.py | 0 {tools => Tools}/linux-capture/test_whoop_sync.py | 0 .../linux-capture/validate_spo2_candidate.py | 0 {tools => Tools}/linux-capture/whoop_activity.py | 0 {tools => Tools}/linux-capture/whoop_buzz.py | 0 {tools => Tools}/linux-capture/whoop_capture.py | 0 {tools => Tools}/linux-capture/whoop_frame.py | 0 {tools => Tools}/linux-capture/whoop_probe.py | 0 {tools => Tools}/linux-capture/whoop_setclock.py | 0 {tools => Tools}/linux-capture/whoop_spot_hrv.py | 0 {tools => Tools}/linux-capture/whoop_sync.py | 0 .../main/java/com/noop/ingest/CaptureImporter.kt | 2 +- docs/ANDROID.md | 4 ++-- docs/BLE_REVERSE_ENGINEERING.md | 14 +++++++------- docs/CONTRIBUTING.md | 9 ++++----- docs/WHOOP5_DEEP_DATA.md | 8 ++++---- 37 files changed, 30 insertions(+), 31 deletions(-) rename {tools => Tools}/linux-capture/.gitignore (100%) rename {tools => Tools}/linux-capture/README.md (99%) rename {tools => Tools}/linux-capture/analyze_command_responses.py (100%) rename {tools => Tools}/linux-capture/analyze_v25_waveform.py (100%) rename {tools => Tools}/linux-capture/analyze_v26_waveform.py (100%) rename {tools => Tools}/linux-capture/correlate_ground_truth.py (100%) rename {tools => Tools}/linux-capture/decode_features.py (100%) rename {tools => Tools}/linux-capture/hci_extract.py (100%) rename {tools => Tools}/linux-capture/pair_probe.py (100%) rename {tools => Tools}/linux-capture/requirements.txt (100%) rename {tools => Tools}/linux-capture/test_correlate_ground_truth.py (100%) rename {tools => Tools}/linux-capture/test_decode_features.py (100%) rename {tools => Tools}/linux-capture/test_hci_extract.py (100%) rename {tools => Tools}/linux-capture/test_validate_spo2_candidate.py (100%) rename {tools => Tools}/linux-capture/test_whoop_activity.py (100%) rename {tools => Tools}/linux-capture/test_whoop_frame.py (100%) rename {tools => Tools}/linux-capture/test_whoop_spot_hrv.py (100%) rename {tools => Tools}/linux-capture/test_whoop_sync.py (100%) rename {tools => Tools}/linux-capture/validate_spo2_candidate.py (100%) rename {tools => Tools}/linux-capture/whoop_activity.py (100%) rename {tools => Tools}/linux-capture/whoop_buzz.py (100%) rename {tools => Tools}/linux-capture/whoop_capture.py (100%) rename {tools => Tools}/linux-capture/whoop_frame.py (100%) rename {tools => Tools}/linux-capture/whoop_probe.py (100%) rename {tools => Tools}/linux-capture/whoop_setclock.py (100%) rename {tools => Tools}/linux-capture/whoop_spot_hrv.py (100%) rename {tools => Tools}/linux-capture/whoop_sync.py (100%) diff --git a/.github/workflows/tools-python.yml b/.github/workflows/tools-python.yml index c5f0fc9b24..fc18f4429a 100644 --- a/.github/workflows/tools-python.yml +++ b/.github/workflows/tools-python.yml @@ -1,25 +1,25 @@ -# The Python test suites under tools/, which nothing ran. +# The Python test suites under Tools/, which nothing ran. # -# `tools/linux-capture` carries 212 tests across 8 files — the frame decoder, the HCI extractor, the +# `Tools/linux-capture` carries 212 tests across 8 files — the frame decoder, the HCI extractor, the # activity and sync decoders, the spot-HRV path, and the SpO2 promote gate that #103 is waiting on. Every # one of them only ever executed when a contributor remembered to, which meant a decoder could regress and # the suite that would have caught it stayed green by never being run. # # Cheap to run: pure stdlib `unittest`, no dependencies, no fixtures to fetch, ubuntu rather than macOS. -# Path-filtered so it only fires when tools/ changes. +# Path-filtered so it only fires when the suites it runs change. name: Tools Python CI on: pull_request: branches: [main] paths: - - 'tools/**' + - 'Tools/linux-capture/**' - 'Tools/*.py' - '.github/workflows/tools-python.yml' push: branches: [main] paths: - - 'tools/**' + - 'Tools/linux-capture/**' - 'Tools/*.py' - '.github/workflows/tools-python.yml' workflow_dispatch: @@ -47,7 +47,7 @@ jobs: # wrong working-directory or a renamed pattern would turn this job green while running no tests at # all. Checking a floor makes that fail loudly instead. Raise the floor when suites are added; do # not lower it to make a red run pass. - - name: Run tools/linux-capture tests + - name: Run Tools/linux-capture tests run: | set -o pipefail # GitHub's default shell is `bash -e` WITHOUT pipefail: piping into tee # would return tee's status and swallow a failing suite entirely. @@ -58,7 +58,7 @@ jobs: echo "::error::expected at least 200 tests, collected ${ran:-0} — discovery is broken, not the suite" exit 1 fi - working-directory: tools/linux-capture + working-directory: Tools/linux-capture # The Tools/ suites: the i18n audit that gates every PR's translations, and the What's New # generator (#878). Neither ran before — i18n-coverage.yml invokes `i18n_audit.py --ci` but never diff --git a/CHANGELOG.md b/CHANGELOG.md index 4839ac4cc9..f939edfde5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3216,7 +3216,7 @@ killed standard-0x2A37 live HR). rate as ground-truth — to a JSON file, with Export / Reveal actions. Read-only on the strap, off by default, and never touches WHOOP 4.0. This is how 5/MG owners can contribute the captures needed to decode recovery / strain / sleep. -- **Dev tooling:** a headless Linux capture workbench (`tools/linux-capture/`, Python + bleak) and a +- **Dev tooling:** a headless Linux capture workbench (`Tools/linux-capture/`, Python + bleak) and a `whoop-decode` CLI that decodes captures with the same `WhoopProtocol` decoder the apps ship — no second decoder to drift. Plus hardware-verified WHOOP 5.0 bonding/session notes in `docs/BLE_REVERSE_ENGINEERING.md` that confirm the v1.5 just-works-bond approach. diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift index 41d37c21a4..b3dac26860 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift @@ -594,7 +594,7 @@ private func decodeWhoop5Historical(_ frame: [UInt8], fb: FieldBuilder, payloadE /// reference): the concatenated waveform's autocorrelation peaks at the HR (lag 14 = 102.9 bpm vs a /// measured 101.7 bpm), trough-detection gives a 563 ms inter-beat interval (≈106 bpm), the pulse stays /// HR-locked even when the wrist is still, and its amplitude is not motion-driven. (Reproduce with -/// `tools/linux-capture/analyze_v26_waveform.py`; see docs §5.) +/// `Tools/linux-capture/analyze_v26_waveform.py`; see docs §5.) /// /// The samples are raw AC-coupled ADC counts — PPG has no absolute unit — so they are exposed verbatim /// as `ppg_waveform` with NO invented scale. The bytes before [27] (header + a block index) and the diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/PpgHr.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/PpgHr.swift index fb42e467e9..ba39f05f0b 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/PpgHr.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/PpgHr.swift @@ -8,7 +8,7 @@ import Foundation /// timeline continuous through the gaps. It is HR-only — PPG carries NO body motion, so this fills HR /// continuity, NOT actigraphy and NOT HRV (the contributor confirmed v26 gives no RMSSD). /// -/// Mirrors `tools/linux-capture/ppg_hr.py` (PR #162, Python side): 8 s autocorrelation window, a +/// Mirrors `Tools/linux-capture/ppg_hr.py` (PR #162, Python side): 8 s autocorrelation window, a /// 30–220 bpm search band (lags 6…48 at 24 Hz), linear detrend, normalised peak as confidence, and a /// fundamental-period preference so the harmonic peaks at 2×/3× the true period don't report half/third /// the real rate. Pure + Foundation-only so it is unit-testable from synthetic and captured waveforms. diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5PpgWaveformTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5PpgWaveformTests.swift index 9af82c23cd..a6507cd822 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5PpgWaveformTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/Whoop5PpgWaveformTests.swift @@ -9,7 +9,7 @@ import XCTest /// the concatenated waveform's autocorrelation peaks at the heart rate (lag 14 = 102.9 bpm vs a measured /// 101.7 bpm), trough-detection gives a 563 ms inter-beat interval (≈106 bpm), the pulse stays HR-locked /// even when the wrist is still, and its amplitude is not motion-driven. See -/// `tools/linux-capture/analyze_v26_waveform.py` and `BLE_REVERSE_ENGINEERING.md` §5. +/// `Tools/linux-capture/analyze_v26_waveform.py` and `BLE_REVERSE_ENGINEERING.md` §5. /// /// Samples are raw AC-coupled ADC counts — PPG has no absolute unit — so they are exposed verbatim with /// no invented scale. Real type-47 frames carry no device name / serial / token, so the fixture is real. diff --git a/tools/linux-capture/.gitignore b/Tools/linux-capture/.gitignore similarity index 100% rename from tools/linux-capture/.gitignore rename to Tools/linux-capture/.gitignore diff --git a/tools/linux-capture/README.md b/Tools/linux-capture/README.md similarity index 99% rename from tools/linux-capture/README.md rename to Tools/linux-capture/README.md index b7955a5164..44c9afb08a 100644 --- a/tools/linux-capture/README.md +++ b/Tools/linux-capture/README.md @@ -48,7 +48,7 @@ protocol details these tools exercise. ## Setup (first time) -Step by step from a fresh machine. Run these from inside this directory (`tools/linux-capture/`). +Step by step from a fresh machine. Run these from inside this directory (`Tools/linux-capture/`). ```bash # 1. System packages: Python venv support + BlueZ (Debian/Ubuntu/Pop!_OS) diff --git a/tools/linux-capture/analyze_command_responses.py b/Tools/linux-capture/analyze_command_responses.py similarity index 100% rename from tools/linux-capture/analyze_command_responses.py rename to Tools/linux-capture/analyze_command_responses.py diff --git a/tools/linux-capture/analyze_v25_waveform.py b/Tools/linux-capture/analyze_v25_waveform.py similarity index 100% rename from tools/linux-capture/analyze_v25_waveform.py rename to Tools/linux-capture/analyze_v25_waveform.py diff --git a/tools/linux-capture/analyze_v26_waveform.py b/Tools/linux-capture/analyze_v26_waveform.py similarity index 100% rename from tools/linux-capture/analyze_v26_waveform.py rename to Tools/linux-capture/analyze_v26_waveform.py diff --git a/tools/linux-capture/correlate_ground_truth.py b/Tools/linux-capture/correlate_ground_truth.py similarity index 100% rename from tools/linux-capture/correlate_ground_truth.py rename to Tools/linux-capture/correlate_ground_truth.py diff --git a/tools/linux-capture/decode_features.py b/Tools/linux-capture/decode_features.py similarity index 100% rename from tools/linux-capture/decode_features.py rename to Tools/linux-capture/decode_features.py diff --git a/tools/linux-capture/hci_extract.py b/Tools/linux-capture/hci_extract.py similarity index 100% rename from tools/linux-capture/hci_extract.py rename to Tools/linux-capture/hci_extract.py diff --git a/tools/linux-capture/pair_probe.py b/Tools/linux-capture/pair_probe.py similarity index 100% rename from tools/linux-capture/pair_probe.py rename to Tools/linux-capture/pair_probe.py diff --git a/tools/linux-capture/requirements.txt b/Tools/linux-capture/requirements.txt similarity index 100% rename from tools/linux-capture/requirements.txt rename to Tools/linux-capture/requirements.txt diff --git a/tools/linux-capture/test_correlate_ground_truth.py b/Tools/linux-capture/test_correlate_ground_truth.py similarity index 100% rename from tools/linux-capture/test_correlate_ground_truth.py rename to Tools/linux-capture/test_correlate_ground_truth.py diff --git a/tools/linux-capture/test_decode_features.py b/Tools/linux-capture/test_decode_features.py similarity index 100% rename from tools/linux-capture/test_decode_features.py rename to Tools/linux-capture/test_decode_features.py diff --git a/tools/linux-capture/test_hci_extract.py b/Tools/linux-capture/test_hci_extract.py similarity index 100% rename from tools/linux-capture/test_hci_extract.py rename to Tools/linux-capture/test_hci_extract.py diff --git a/tools/linux-capture/test_validate_spo2_candidate.py b/Tools/linux-capture/test_validate_spo2_candidate.py similarity index 100% rename from tools/linux-capture/test_validate_spo2_candidate.py rename to Tools/linux-capture/test_validate_spo2_candidate.py diff --git a/tools/linux-capture/test_whoop_activity.py b/Tools/linux-capture/test_whoop_activity.py similarity index 100% rename from tools/linux-capture/test_whoop_activity.py rename to Tools/linux-capture/test_whoop_activity.py diff --git a/tools/linux-capture/test_whoop_frame.py b/Tools/linux-capture/test_whoop_frame.py similarity index 100% rename from tools/linux-capture/test_whoop_frame.py rename to Tools/linux-capture/test_whoop_frame.py diff --git a/tools/linux-capture/test_whoop_spot_hrv.py b/Tools/linux-capture/test_whoop_spot_hrv.py similarity index 100% rename from tools/linux-capture/test_whoop_spot_hrv.py rename to Tools/linux-capture/test_whoop_spot_hrv.py diff --git a/tools/linux-capture/test_whoop_sync.py b/Tools/linux-capture/test_whoop_sync.py similarity index 100% rename from tools/linux-capture/test_whoop_sync.py rename to Tools/linux-capture/test_whoop_sync.py diff --git a/tools/linux-capture/validate_spo2_candidate.py b/Tools/linux-capture/validate_spo2_candidate.py similarity index 100% rename from tools/linux-capture/validate_spo2_candidate.py rename to Tools/linux-capture/validate_spo2_candidate.py diff --git a/tools/linux-capture/whoop_activity.py b/Tools/linux-capture/whoop_activity.py similarity index 100% rename from tools/linux-capture/whoop_activity.py rename to Tools/linux-capture/whoop_activity.py diff --git a/tools/linux-capture/whoop_buzz.py b/Tools/linux-capture/whoop_buzz.py similarity index 100% rename from tools/linux-capture/whoop_buzz.py rename to Tools/linux-capture/whoop_buzz.py diff --git a/tools/linux-capture/whoop_capture.py b/Tools/linux-capture/whoop_capture.py similarity index 100% rename from tools/linux-capture/whoop_capture.py rename to Tools/linux-capture/whoop_capture.py diff --git a/tools/linux-capture/whoop_frame.py b/Tools/linux-capture/whoop_frame.py similarity index 100% rename from tools/linux-capture/whoop_frame.py rename to Tools/linux-capture/whoop_frame.py diff --git a/tools/linux-capture/whoop_probe.py b/Tools/linux-capture/whoop_probe.py similarity index 100% rename from tools/linux-capture/whoop_probe.py rename to Tools/linux-capture/whoop_probe.py diff --git a/tools/linux-capture/whoop_setclock.py b/Tools/linux-capture/whoop_setclock.py similarity index 100% rename from tools/linux-capture/whoop_setclock.py rename to Tools/linux-capture/whoop_setclock.py diff --git a/tools/linux-capture/whoop_spot_hrv.py b/Tools/linux-capture/whoop_spot_hrv.py similarity index 100% rename from tools/linux-capture/whoop_spot_hrv.py rename to Tools/linux-capture/whoop_spot_hrv.py diff --git a/tools/linux-capture/whoop_sync.py b/Tools/linux-capture/whoop_sync.py similarity index 100% rename from tools/linux-capture/whoop_sync.py rename to Tools/linux-capture/whoop_sync.py diff --git a/android/app/src/main/java/com/noop/ingest/CaptureImporter.kt b/android/app/src/main/java/com/noop/ingest/CaptureImporter.kt index 87ae2609a2..4ef997d55c 100644 --- a/android/app/src/main/java/com/noop/ingest/CaptureImporter.kt +++ b/android/app/src/main/java/com/noop/ingest/CaptureImporter.kt @@ -24,7 +24,7 @@ import java.time.format.DateTimeParseException import java.time.temporal.ChronoUnit /** - * Imports a `capture.json` produced by tools/linux-capture/whoop_sync.py `export` into the local + * Imports a `capture.json` produced by Tools/linux-capture/whoop_sync.py `export` into the local * Room store by running its raw strap frames through the SAME on-device historical decoder the live * BLE offload uses (extractHistoricalStreams -> WhoopRepository.insert). No new decode logic; this is * pure file -> frames -> decode -> store plumbing, so externally-captured history (e.g. a Linux BLE diff --git a/docs/ANDROID.md b/docs/ANDROID.md index 3c252e3a52..2a4f2d0599 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -509,7 +509,7 @@ storage; nothing is uploaded. ## Raw capture import (`capture.json`) **Settings → Data Sources → "Raw capture (.json)"** imports a strap offload that was captured on -*another* device — most usefully the Linux `tools/linux-capture/whoop_sync.py` tool, whose `export` +*another* device — most usefully the Linux `Tools/linux-capture/whoop_sync.py` tool, whose `export` subcommand writes exactly this format. It lets you pull a strap's history on a laptop (no phone, no WHOOP app) and then fold it into NOOP on the phone. Fully offline; nothing leaves the device. @@ -562,7 +562,7 @@ post-import rescore via `CaptureImporter.analyzeWindowDays(firstDay, today)`: not calibrated `%`/`°C`; the engine does not derive daily SpO₂ / skin-temp from them, so those daily fields stay null on a 4.0 import. -See `tools/linux-capture/README.md` for the capture/export side that produces these files. +See `Tools/linux-capture/README.md` for the capture/export side that produces these files. --- diff --git a/docs/BLE_REVERSE_ENGINEERING.md b/docs/BLE_REVERSE_ENGINEERING.md index 44f86962af..bf7cb94ac6 100644 --- a/docs/BLE_REVERSE_ENGINEERING.md +++ b/docs/BLE_REVERSE_ENGINEERING.md @@ -205,7 +205,7 @@ exposed as `DeviceFamily.whoop5ClientHello`. ### Bonding and the puffin session (hardware-verified) -Confirmed against a real WHOOP 5 strap using the Linux capture tooling in `tools/linux-capture/` (a +Confirmed against a real WHOOP 5 strap using the Linux capture tooling in `Tools/linux-capture/` (a `bleak`/BlueZ capture feeding the `whoop-decode` CLI). The notes below supersede the earlier "unverified on MG hardware" caveats for the connect path. @@ -262,7 +262,7 @@ fall through to "unknown": > bonds, accepts the 4.0 command numbers, and performs a full historical offload, all decoding > CRC-valid. The 5.0 **biometric field offsets** are now mapped from real captures too — live > `REALTIME_DATA` (§5) and the historical type-47 record (version 18, §5) both decode HR / R-R / -> gravity, validated against ground truth. Capture with `tools/linux-capture/whoop_capture.py +> gravity, validated against ground truth. Capture with `Tools/linux-capture/whoop_capture.py > --history-only --history-ack` and decode with `whoop-decode`. --- @@ -378,7 +378,7 @@ raw rows first, then sends `HISTORICAL_DATA_RESULT` (23) as a confirmed write ec The offload runs at a steady **~10 type-47 records per second**, and since the records are 1 Hz that is only **~10× real-time** (a full day ≈ 40 min, a night ≈ 30 min). This is a property of the strap -firmware, **not** the BLE link. Measured on a real worn WHOOP 4 (`tools/linux-capture/`), the rate did +firmware, **not** the BLE link. Measured on a real worn WHOOP 4 (`Tools/linux-capture/`), the rate did not move when either link parameter was forced upward: - **ATT MTU 23 → 247** — a 104-byte type-47 frame goes from 6 notification packets to 1. No change. @@ -397,7 +397,7 @@ adding `requestConnectionPriority`/`requestMtu` to a client to speed *this* offl ### WHOOP 5.0 historical offload (hardware-verified) The ack is not just for resumability on WHOOP 5 — **it is what makes the offload progress at all.** -Confirmed on a real worn WHOOP 5 (latest firmware) via `tools/linux-capture/`: +Confirmed on a real worn WHOOP 5 (latest firmware) via `Tools/linux-capture/`: - **Without acking**, the strap re-serves the *same* early chunk forever. Across 16 deterministic re-requests the `trim_cursor` stayed frozen at `112193` and **zero** type-47 records arrived — only @@ -518,7 +518,7 @@ resolves, `@82` stays a candidate. **What clears the bar is multi-device correlation, not one more capture** — the nightly candidate tracking the app's own SpO₂ across many nights on several straps, *including* the device where the two -nights currently disagree. `tools/linux-capture/validate_spo2_candidate.py` is the harness for exactly +nights currently disagree. `Tools/linux-capture/validate_spo2_candidate.py` is the harness for exactly that. A single asleep frame proves nothing here; the value range has been seen. **Independent corroboration (#715).** `whoop-local` reads the same byte the same way — sleep-only, and @@ -584,7 +584,7 @@ The full v26 byte map (88 bytes; CRC32 @84): `decodeWhoop5HistoricalV26` exposes `ppg_waveform` (+ `ppg_sample_count`), `ppg_channel`, and `unix`. The samples are raw AC-coupled ADC counts — PPG has no absolute unit — so no scale is invented; the high-entropy `23–26` and the footer are left raw (no internal ground truth). Reproduce the proof with -`tools/linux-capture/analyze_v26_waveform.py`; parity tests `Whoop5PpgWaveformTests.swift`. +`Tools/linux-capture/analyze_v26_waveform.py`; parity tests `Whoop5PpgWaveformTests.swift`. ### The WHOOP 5.0 / MG type-47 records (versions 20 & 21) — bulk multi-channel sensor stream @@ -778,7 +778,7 @@ frame. The strap acknowledges acceptance with `COMMAND_RESPONSE` (type 36) echoi `RUN_HAPTIC_PATTERN_MAVERICK(19)`. **Verified on real hardware (2026-06-12):** a bonded WHOOP 5 buzzed on this exact frame and returned a -CRC-valid COMMAND_RESPONSE for every send. Frame builders live in `tools/linux-capture/whoop_frame.py` +CRC-valid COMMAND_RESPONSE for every send. Frame builders live in `Tools/linux-capture/whoop_frame.py` (`build_whoop5_buzz` / `build_whoop4_buzz`, unit-tested against the captured frame) and drive the `whoop_buzz.py` find-my-strap tool — see the linux-capture README. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 0a7e9e9ae8..ad0eb8d170 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -87,8 +87,7 @@ Strand/ │ ├── StrandImport/ # WHOOP CSV + Apple Health importers │ └── StrandDesign/ # SwiftUI design system (palette, components, charts) ├── Tools/ -│ └── Backfill/ # `swift run backfill` — re-runs importers into the on-device DB -├── tools/ +│ ├── Backfill/ # `swift run backfill` — re-runs importers into the on-device DB │ └── linux-capture/ # Headless Linux capture workbench (Python/bleak + whoop-decode) ├── Fixtures/ # Sample WHOOP export used by tests └── android/ # Android client — full shipped app (Kotlin/Gradle, separate module) @@ -105,7 +104,7 @@ Strand/ | Colors, fonts, motion, cards, charts | `Packages/StrandDesign` | No external UI deps; bridges AppKit/UIKit. | | CoreBluetooth, bonding, offload, live state | `Strand/BLE`, `Strand/Collect` | macOS-app layer — wraps the pure packages. | | A screen, sidebar item, menu-bar UI, automation | `Strand/Screens`, `Strand/App`, `Strand/System` | App layer. | -| Capturing strap frames on Linux for protocol RE | `tools/linux-capture` | Python/bleak capture → `whoop-decode`; no Mac/CoreBluetooth. See its [README](../tools/linux-capture/README.md). | +| Capturing strap frames on Linux for protocol RE | `Tools/linux-capture` | Python/bleak capture → `whoop-decode`; no Mac/CoreBluetooth. See its [README](../Tools/linux-capture/README.md). | **Rule of thumb:** the more "wire-level" or "math-level" a change is, the deeper into `Packages/` it should live, and the more it should be covered by a `swift test` suite that runs without an app, a @@ -172,12 +171,12 @@ cd Packages/WhoopProtocol swift build && swift test # decoder + its tests, on Linux swift build --product whoop-decode # the decode CLI → .build/debug/whoop-decode -cd ../../tools/linux-capture +cd ../../Tools/linux-capture python3 -m unittest -v # framing/reassembly tests (stdlib only, no bleak) ``` Capturing from a real strap on Linux is documented in -[`../tools/linux-capture/README.md`](../tools/linux-capture/README.md). +[`../Tools/linux-capture/README.md`](../Tools/linux-capture/README.md). ### macOS app diff --git a/docs/WHOOP5_DEEP_DATA.md b/docs/WHOOP5_DEEP_DATA.md index 2611eed7de..c79e94e6b6 100644 --- a/docs/WHOOP5_DEEP_DATA.md +++ b/docs/WHOOP5_DEEP_DATA.md @@ -149,11 +149,11 @@ Until that clears the bar, SpO₂ stays import-only on the 5.0. ### Multi-device validation tool (`validate_spo2_candidate.py`) -To make that bar concrete and privacy-preserving, `tools/linux-capture/validate_spo2_candidate.py` +To make that bar concrete and privacy-preserving, `Tools/linux-capture/validate_spo2_candidate.py` turns one or more `(capture.json, WHOOP export)` pairs into a promote checklist: ```bash -cd tools/linux-capture +cd Tools/linux-capture python3 validate_spo2_candidate.py capture.json my_whoop_data/ --device strap-a --postable python3 validate_spo2_candidate.py --batch devices.json --postable ``` @@ -205,7 +205,7 @@ per-night values — HRV, resting HR, skin temperature, SpO₂, respiratory rate in the capture. Searching each record type for the byte offset + encoding that reproduces those known values across every night pins the field without guesswork. -Three stdlib tools in [`tools/linux-capture/`](../tools/linux-capture/) do this: +Three stdlib tools in [`Tools/linux-capture/`](../Tools/linux-capture/) do this: - **`hci_extract.py`** converts a phone HCI log (iOS `.pklg` / Android `btsnoop_hci.log`) of the official app into the project's `capture.json` frame format — so an official-app full-sync capture @@ -235,7 +235,7 @@ lands in `parseFrameWhoop5` / `whoop_protocol.json`. same iOS-HCI approach the [judes.club write-up](https://judes.club/writing/cracking-the-whoop-5-bluetooth-protocol/) used. Filter to just the WHOOP peripheral and attach it to [#103](https://github.com/ryanbr/noop/issues/103). 5. **SpO₂ multi-device check:** after you have a history capture + your data export, run - `python3 tools/linux-capture/validate_spo2_candidate.py capture.json export/ --device