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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/tools-python.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,20 @@ Strand/Oura/OuraSecrets.xcconfig

# Personal bundle id prefix (BYO; never committed)
Config/BundleIdSecrets.xcconfig

# (#979) The linux-capture workbench moved from `tools/` to `Tools/` when the two case-colliding
# trees were merged. On a case-SENSITIVE filesystem any capture a contributor already had stays
# behind at the old path, where that tool's own .gitignore no longer reaches it — and those files
# carry HR and timeline, while the .db carries the strap serial, session token and MAC.
#
# These are DATA patterns only, never the bare directory. Git folds case in .gitignore when
# core.ignorecase is on (which it is on macOS), so `tools/linux-capture/` would also match the REAL
# `Tools/linux-capture/` and silently ignore every new file added there — a new test suite would
# vanish, which is the exact failure this whole normalisation exists to prevent. Every pattern below
# is one that Tools/linux-capture/.gitignore already applies, so folding onto the real tree is a
# no-op there while the orphaned old path stays covered here.
tools/linux-capture/*.json
tools/linux-capture/*.db
tools/linux-capture/*.db-wal
tools/linux-capture/*.db-shm
tools/linux-capture/captures/
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Packages/WhoopProtocol/Sources/WhoopProtocol/PpgHr.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/ANDROID.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

---

Expand Down
14 changes: 7 additions & 7 deletions docs/BLE_REVERSE_ENGINEERING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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`.

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

Expand Down Expand Up @@ -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.

Expand Down
9 changes: 4 additions & 5 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions docs/WHOOP5_DEEP_DATA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <label> --postable`
`python3 Tools/linux-capture/validate_spo2_candidate.py capture.json export/ --device <label> --postable`
and paste the postable summary on [#103](https://github.com/ryanbr/noop/issues/103). Keep the capture
and CSV private; only the aggregate r / MAE / checklist line is needed.

Expand Down
Loading