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
11 changes: 11 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# The TypeScript under ts/ is the frozen reference oracle: it generated
# decode_parity_cases.json (2934 cases) and dart_header.json (550 cases), which
# the Dart suite asserts against on every run. It is kept for provenance and
# regeneration, not shipped or imported by anything.
#
# Marking it vendored keeps GitHub's language bar reporting what this package
# actually is — pure Dart — without deleting the oracle.
ts/** linguist-vendored
*.json linguist-generated
decode_parity_cases.json linguist-generated
dart_header.json linguist-generated
3 changes: 3 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# GitHub funding config only accepts URLs, not raw wallet addresses.
custom:
- https://github.com/OpenStrap/edge/blob/main/DONATE.md
47 changes: 47 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: test

on:
push:
branches: [main]
pull_request:
workflow_dispatch:
Comment on lines +3 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '1,220p' .github/workflows/test.yml

Repository: OpenStrap/protocol

Length of output: 1080


Run CI on every push, not only main.
push.branches: [main] skips direct pushes to feature branches. Drop the branch filter if this workflow should run for every push.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/test.yml around lines 3 - 7, Update the workflow trigger
configuration under on.push to remove the branches filter, ensuring the workflow
runs on every push while preserving the existing pull_request and
workflow_dispatch triggers.


# Read-only: this job only builds and tests. Nothing here needs write access,
# and it executes code from pull requests.
permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# pubspec declares `sdk: ^3.5.0`, so 3.5.0 is a claim this package makes
# to anyone depending on it. Test it, rather than only testing whatever
# `stable` happens to be — otherwise the lower bound is a guess.
sdk: ['3.5.0', 'stable']
name: test (Dart ${{ matrix.sdk }})
steps:
- uses: actions/checkout@v4
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
# Don't leave GITHUB_TOKEN in .git/config where PR-authored test code
# could read it. Nothing in this job pushes back to the repo.
persist-credentials: false
- uses: dart-lang/setup-dart@v1
with:
sdk: ${{ matrix.sdk }}

- name: Install dependencies
run: dart pub get

- name: Analyze
run: dart analyze --fatal-infos

# The golden capture (whoop_hist.jsonl) is a real band recording kept
# beside the repo rather than committed to it, so the tests that need it
# SKIP here and run locally. Everything else — including the 2934-case
# decode_parity_cases.json oracle and the 550 dart_header.json cases —
# is tracked in-repo and runs on every PR.
- name: Test
run: dart test --reporter=expanded
87 changes: 87 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Contributing

This package is the byte layer: bytes off the band in, named records out. Keep
it that way — it has **zero runtime dependencies** and no I/O, and that's a
feature, not an accident. It runs on-device inside the app.

## What belongs here (and what doesn't)

| Your change | Repo |
|---|---|
| A record type, opcode, event, CRC/framing, GATT constant | **here** |
| A metric, or how a number is computed from records | [analytics](https://github.com/OpenStrap/analytics) |
| Bluetooth reliability, storage, sync, UI | [edge](https://github.com/OpenStrap/edge) |

Nothing in `lib/` may import `dart:io`, do network calls, or pull in a package.
If you need those, your change belongs in edge.

## Protocol findings are the most valuable contribution

A lot of the current event table is empirical guesswork by one person. If you've
worked out a field, an opcode, or an event we don't decode — or found one we
decode *wrongly* — that helps everyone with one of these bands.

Open an issue with:

- The raw frame bytes (hex), as many samples as you have.
- What you think the field is, and at what offset.
- **How you convinced yourself.** This is the important part. "HR at offset 17
tracked my actual pulse across 40 minutes of wear, and offset 14 didn't" is a
real answer. Correlating against a known ground truth beats pattern-matching.

Firmware versions differ. If a field only holds on one `histVersion`, say which
one — `parseR24` already routes per-version and that's usually where a new
finding lands.

## Ground rules

**Don't decode something you can't justify.** A wrong field is worse than a
missing one, because it silently poisons every metric downstream. If a value is
only *probably* right, gate it behind a plausibility check the way
`_physiologicallyPlausible` does (HR 25–230, |g|² 0.25–3.24) rather than
trusting it outright.

**Never guess a value to fill a gap.** If a record doesn't decode, return
`null`. The app archives undecodable records rather than discarding them, so a
`null` here is recoverable later; a fabricated value is not.

**Some opcodes are dangerous.** `dangerousCmds` exists for a reason — force-trim,
reboot, power-cycle, firmware load. Don't wire any of them into an automatic
path, and don't add new ones without a very clear justification in the PR.

## Tests

Run from the repo root:

```bash
dart pub get
dart analyze
dart test
```

Two things guard every change:

- **`decode_parity_cases.json`** — 2934 cases checked against the frozen
TypeScript oracle in `ts/`. If you change a decoder and parity breaks, either
your change is wrong or the oracle needs regenerating — work out which, and
say so in the PR.
- **`dart_header.json`** — 550 hand-checked R24 header cases.

Both are tracked in-repo and run in CI. A third set replays
`whoop_hist.jsonl`, a real band capture kept *beside* the repo rather than in
it; those tests skip automatically when it's absent, which is what CI does.

New record types or opcodes should come with test cases from real frames.

## Pull requests

- Branch off `main`, one logical change per PR.
- Say how you verified it against real hardware.
- No `Co-Authored-By` trailers.

## Scope

Facts about a wire protocol, worked out by observing your own device, are fine.
Vendor source code, firmware, decompiled binaries, and material from other
reverse-engineering projects whose licences don't permit reuse are not — don't
paste them into code, comments, commits, or PR descriptions.
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# OpenStrap protocol

[![test](https://github.com/OpenStrap/protocol/actions/workflows/test.yml/badge.svg)](https://github.com/OpenStrap/protocol/actions/workflows/test.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

Pure Dart, zero runtime deps. You hand it an already-unwrapped chunk of bytes from the
band, it hands you back a record with named fields, or a decoded command/event. That's
the whole job.
Expand Down Expand Up @@ -126,7 +129,7 @@ Pure Dart, no Flutter dependency:

```bash
dart pub get
dart test # 70 tests, incl. the 2934-case TS-parity suite
dart test # 71 tests, incl. the 2934-case TS-parity suite
```

Run tests from the repo root — the parity fixture (`decode_parity_cases.json`) is
Expand All @@ -142,3 +145,20 @@ check `FirmwareAwareR24Decoder` first — chances are your case fits the existin
shape rather than needing a new one.

Cross-checking against `_external/noop/` `bWanShiTong/reverse-engineering-whoop-post/` for facts/techniques is fine; copying its code is not.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) — which repo a change belongs in, how to run the
tests, and the rules that keep this package honest. Security issues go through
[SECURITY.md](SECURITY.md), not a public issue.

## Support the work

Free, MIT, no company behind it. If OpenStrap gave your band a second life,
[DONATE.md](https://github.com/OpenStrap/edge/blob/main/DONATE.md) has BTC and EVM
addresses. Protocol findings and bug reports are worth more than money, though.

---

Not affiliated with, endorsed by, or connected to WHOOP. "WHOOP" is their trademark, used
only to say which device this talks to.
59 changes: 59 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Security Policy

## Reporting a vulnerability

Please **don't** open a public issue for a security problem.

Use GitHub's private reporting instead:
[**Report a vulnerability →**](https://github.com/OpenStrap/protocol/security/advisories/new)

That goes straight to the maintainer and stays private until there's a fix.

Rough expectations, set honestly — this is a one-maintainer project, not a
company with an on-call rota:

- Acknowledgement within about a week.
- An assessment, and a fix or a clear "won't fix and here's why", within 30 days
for anything that puts user data at risk.
- Credit in the release notes if you want it.

## What's in scope

- Anything that discloses a user's health data off their device.
- Anything that lets a third party read, write to, or hijack the Bluetooth
session with someone's band.
- Local data-at-rest problems: the database, exports, the App Group container,
widget snapshots.
- The optional companion worker in
[backend](https://github.com/OpenStrap/backend): auth, the import endpoints,
the opt-in telemetry and health-upload paths.
- Anything that causes the app to send data anywhere the user did not agree to.

## What's out of scope

- The band's own firmware. We don't ship it, can't patch it, and won't publish
attacks against it.
- WHOOP's own apps and services. Please report those to WHOOP.
- The fact that sideloaded builds are unsigned, or that a rooted/jailbroken
device can read app storage. Both are known properties of the distribution
model, documented in the README.
- Metric accuracy. Wrong numbers are bugs — open a normal issue.

## Where your data actually is

Worth knowing before you go looking: OpenStrap computes and stores your health
data on-device, and there's no account or server holding it. Two qualifications,
so the boundary is exact:

- **Anonymous diagnostics** (Firebase crash/performance, never health data) are
**on by default in GitHub release builds** and absent from App Store / Play
Store builds. Switchable off in-app.
- **Health-data contribution** uploads the local database, but is opt-in, off by
default, and compiled out of store builds entirely.

Everything else the companion worker does — legacy import, an update pointer —
is optional and carries no health data. See [edge's PRIVACY.md](https://github.com/OpenStrap/edge/blob/main/PRIVACY.md).

That means the realistic attack surface is the phone, the Bluetooth link, and
the local database — not a cloud backend. Reports focused there are the most
useful.
47 changes: 29 additions & 18 deletions test/decoder_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,38 @@ import 'dart:typed_data';
import 'package:openstrap_protocol/openstrap_protocol.dart';
import 'package:test/test.dart';

/// The golden fixture is a real band capture that lives beside the repo rather
/// than inside it, so it is present for local runs and absent in CI. Resolve it
/// once up front: when it is missing the fixture-backed groups SKIP instead of
/// failing, which keeps CI honest (green means "everything runnable passed")
/// without pretending the fixture ran.
File? _findHistFixture() {
for (final c in const ['../whoop_hist.jsonl', 'whoop_hist.jsonl']) {
final f = File(c);
if (f.existsSync()) return f;
}
return null;
}

void main() {
final histFixture = _findHistFixture();
final skipHist = histFixture == null
? 'whoop_hist.jsonl fixture not found beside the repo'
: null;

group('whoop_hist.jsonl golden fixture (550 records)', () {
late List<Map<String, dynamic>> records;

setUpAll(() {
final candidates = ['../whoop_hist.jsonl', 'whoop_hist.jsonl'];
File? f;
for (final c in candidates) {
if (File(c).existsSync()) {
f = File(c);
break;
}
}
if (f == null) {
throw StateError(
'whoop_hist.jsonl fixture not found (looked in $candidates)');
}
records = f
.readAsLinesSync()
.where((l) => l.trim().isNotEmpty)
.map((l) => json.decode(l) as Map<String, dynamic>)
.toList();
// NOTE: package:test still runs setUpAll for a skipped group, so this has
// to tolerate the fixture being absent rather than assert on it.
records = histFixture == null
? const []
: histFixture
.readAsLinesSync()
.where((l) => l.trim().isNotEmpty)
.map((l) => json.decode(l) as Map<String, dynamic>)
.toList();
});

test('loaded 550 records', () {
Expand Down Expand Up @@ -71,8 +81,9 @@ void main() {
}
expect(ok, records.length);
});
});
}, skip: skipHist);

// dart_header.json IS tracked in-repo, so this group always runs in CI.
group('dart_header.json (550 R24 header cases)', () {
test('counter/ts_epoch/ts_subsec/hr all match', () {
final cases =
Expand Down
Loading