From 8e172c28b91cee71e21488853e7c80c7f9305e90 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 26 Jul 2026 11:17:28 +0530 Subject: [PATCH 1/7] ci: run analyze + the full test suite on every PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 623 tests existed and nothing ran them on a pull request. The only workflow in the repo was the tag-triggered release build, so the actual review gate on contributions was three AI reviewers and no test run. Adds flutter analyze + flutter test --concurrency=1 on push and PR. --concurrency=1 is required: the suite runs sqflite_common_ffi against real database files and parallel workers race on them. Two things had to be fixed for a first run to be green rather than red, because a red baseline badge is worse than no badge: * derivation_pipeline_test hard-failed without whoop_hist.jsonl. That fixture is a real band capture kept beside the repo rather than in it, so it is present locally and absent in CI. Those two tests now skip when it is missing, matching the analytics suite. 623 pass locally. * eight pre-existing analyze findings. Individually trivial, but they meant `flutter analyze` had never exited zero: - theme.dart imported CupertinoPageTransitionsBuilder from cupertino.dart, which does not declare it. material.dart re-exports it, and that import was already in scope — so the line was both redundant and wrong. - _Stat.valueColor in live_session_screen was never passed by any of its seven call sites; the value always fell through to the default. Removed the dead parameter, not the behaviour. - three underscore-prefixed locals in substrate.dart, renamed. - one null-aware map entry in a test. glassBoxReadiness in crossday_pipeline is deliberately NOT migrated. It is deprecated in favour of readinessComposite, but switching it changes user-visible scores and needs a kAlgoVersion bump and a release note, so it stays an explicit open decision with an ignore comment explaining why, rather than being silently changed to make a linter happy. --- .github/workflows/test.yml | 40 ++++++++++++++++++++++++ docs/{index.html => legal.html} | 0 lib/compute/crossday_pipeline.dart | 9 ++++++ lib/compute/substrate.dart | 26 +++++++-------- lib/theme/theme.dart | 4 ++- lib/ui/activity/live_session_screen.dart | 6 ++-- test/derivation_pipeline_test.dart | 11 +++++-- test/readiness_flash_test.dart | 2 +- 8 files changed, 77 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/test.yml rename docs/{index.html => legal.html} (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..d4669ada --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: test + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + # Sibling packages resolve from their pinned commit SHAs in pubspec.yaml. + # There is no pubspec_overrides.yaml here — that file is gitignored and + # local-dev only — so CI tests exactly the pins a release would ship. + - name: Install dependencies + run: flutter pub get + + - name: Analyze + run: flutter analyze + + # --concurrency=1: the suite uses sqflite_common_ffi against real database + # files, and parallel workers race on them. + # + # The golden capture (whoop_hist.jsonl) is a real band recording kept + # beside the repo rather than committed to it, so the two derivation-replay + # tests SKIP here and run locally. + - name: Test + run: flutter test --concurrency=1 --reporter=expanded diff --git a/docs/index.html b/docs/legal.html similarity index 100% rename from docs/index.html rename to docs/legal.html diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart index e3931c95..ba4e036a 100644 --- a/lib/compute/crossday_pipeline.dart +++ b/lib/compute/crossday_pipeline.dart @@ -147,6 +147,15 @@ Map buildCrossDayBundle( lowerIsBetter: true, ); if (gbTemp != null) gbInputs.add(gbTemp); + // NOT the headline score — `readinessComposite` is, and it is computed + // elsewhere. This call is kept only for glassBoxReadiness's percentile-of-you + // breakdown and deterministic narrative, which readinessComposite does not + // produce, and for back-compat with the stored "readiness_glassbox" key. + // + // Migrating this to readinessComposite is a deliberate open decision, not an + // oversight: it changes user-visible numbers, so it needs a kAlgoVersion bump + // and a release note. Suppressed rather than silently switched. + // ignore: deprecated_member_use final glassBox = ana.glassBoxReadiness(gbInputs); // ── breathing-rate variability across the resp-rate series ───────────────── diff --git a/lib/compute/substrate.dart b/lib/compute/substrate.dart index a1f754c1..c497bd80 100644 --- a/lib/compute/substrate.dart +++ b/lib/compute/substrate.dart @@ -184,35 +184,35 @@ class Substrate { }; static Substrate fromJson(Map m) { - List _i(Map m, String k) => + List ints(Map m, String k) => ((m[k] as List?) ?? const []).map((e) => (e as num).toInt()).toList(); List dbls(String k) => ((m[k] as List?) ?? const []).map((e) => (e as num).toDouble()).toList(); - final tsSec = _i(m, 'ts_sec'); + final tsSec = ints(m, 'ts_sec'); final n = tsSec.length; - List _safeI(String k) { - final l = _i(m, k); + List safeI(String k) { + final l = ints(m, k); return (l.isEmpty && n > 0) ? List.filled(n, 0) : l; } - List _safeD(String k) { + List safeD(String k) { final l = dbls(k); return (l.isEmpty && n > 0) ? List.filled(n, 0.0) : l; } return Substrate( tsSec: tsSec, - hr: _safeI('hr'), + hr: safeI('hr'), rrTsMs: dbls('rr_ts_ms'), // rrTsMs and rrMs don't have to match n rrMs: dbls('rr_ms'), - ax: _safeD('ax'), - ay: _safeD('ay'), - az: _safeD('az'), - spo2Red: _safeI('spo2_red'), - spo2Ir: _safeI('spo2_ir'), - skinTemp: _safeI('skin_temp'), - skinContact: _safeI('skin_contact'), + ax: safeD('ax'), + ay: safeD('ay'), + az: safeD('az'), + spo2Red: safeI('spo2_red'), + spo2Ir: safeI('spo2_ir'), + skinTemp: safeI('skin_temp'), + skinContact: safeI('skin_contact'), ); } } diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 85b9fc34..568a73ca 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -25,7 +25,9 @@ // [Palette] (not the live getters) so the light + dark ThemeData objects are // each internally consistent regardless of which mode is currently active. -import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder; +// CupertinoPageTransitionsBuilder (used below for iOS/macOS) is re-exported by +// material.dart — importing it from cupertino.dart as well was redundant, and +// cupertino.dart does not actually declare it. import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'page_transitions.dart'; diff --git a/lib/ui/activity/live_session_screen.dart b/lib/ui/activity/live_session_screen.dart index 6b3bd11b..396fe28c 100644 --- a/lib/ui/activity/live_session_screen.dart +++ b/lib/ui/activity/live_session_screen.dart @@ -854,18 +854,16 @@ class _GpsControlPanel extends StatelessWidget { class _Stat extends StatelessWidget { final String label, value, unit; final OsIcon icon; - final Color? valueColor; const _Stat({ required this.label, required this.value, required this.unit, required this.icon, - this.valueColor, }); @override Widget build(BuildContext context) { return Column(mainAxisSize: MainAxisSize.min, children: [ - AppIcon(icon, size: 16, color: valueColor?.withValues(alpha: 0.7) ?? Colors.white38), + AppIcon(icon, size: 16, color: Colors.white38), const SizedBox(height: Sp.x2), // mainAxisSize.min + explicit centering: when this _Stat sits inside // an Expanded (the merged GPS stat row), a bare default Row here @@ -879,7 +877,7 @@ class _Stat extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ - Text(value, style: AppText.metric.copyWith(color: valueColor ?? Colors.white, fontSize: 24)), + Text(value, style: AppText.metric.copyWith(color: Colors.white, fontSize: 24)), if (unit.isNotEmpty) ...[const SizedBox(width: 4), Text(unit, style: AppText.caption.copyWith(color: Colors.white38))], ], ), diff --git a/test/derivation_pipeline_test.dart b/test/derivation_pipeline_test.dart index 429e902a..b0152956 100644 --- a/test/derivation_pipeline_test.dart +++ b/test/derivation_pipeline_test.dart @@ -34,6 +34,13 @@ void main() { // never from receive time. decodeRecTs is the pure resolver used at insert AND // in the v6 migration backfill — if it returned the fallback (≈now) the whole // multi-day backfill would collapse into one "today" bucket and hang derivation. + // The fixture is a real band capture kept beside the repo, not inside it, so + // it is there for local runs and absent in CI. Skip rather than fail when it + // is missing — a green CI must not depend on an untracked file. + final skipNoFixture = fixtureFile() == null + ? 'whoop_hist.jsonl fixture not found beside the repo' + : null; + test('decodeRecTs reads the frame\'s real ts, not the fallback', () { final f = fixtureFile(); expect(f, isNotNull, reason: 'whoop_hist.jsonl fixture not found'); @@ -59,7 +66,7 @@ void main() { expect(decodedCount, greaterThan(50), reason: 'decoded real frames'); // Every decoded frame bucketed by its own real day (here all one day). expect(dayLabels, isNotEmpty); - }); + }, skip: skipNoFixture); test('V2 path: decodeSubstrate → segmentation → deriveDayBundle is sane', () { final f = fixtureFile(); @@ -194,7 +201,7 @@ void main() { final rhrMetric = (daily['resting_hr'] as Map).cast(); expect(rhrMetric['value'], isNotNull); expect(rhrMetric['value'], isNot('—')); - }); + }, skip: skipNoFixture); } /// Minimal mirror of LocalRepositoryImpl.getToday() shaping (no DB). diff --git a/test/readiness_flash_test.dart b/test/readiness_flash_test.dart index 00a7f606..2bef014b 100644 --- a/test/readiness_flash_test.dart +++ b/test/readiness_flash_test.dart @@ -21,7 +21,7 @@ TodayData _today({ bool showingPrior = false, }) { return TodayData.fromJson({ - 'daily': {if (readiness != null) 'readiness': readiness}, + 'daily': {'readiness': ?readiness}, 'sleep': const {}, if (overnightState != null) 'status': { From e0aa467a5c9141a007c2236d17d8e06091723343 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 26 Jul 2026 11:17:53 +0530 Subject: [PATCH 2/7] docs: TestFlight install, a real landing page, and a contributor on-ramp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public surface was lagging well behind the app. Install. iOS was documented as "sideload an unsigned IPA", but a public TestFlight beta has been live and open for a while with no cap on testers. That is a normal install with no computer involved, and it was nowhere in the README. Adds it as the primary iOS path, keeps the sideload guide for anyone who prefers it, and drops the stale "sideload only" line. Also flags WHOOP 5 / MG as detected-but-unvalidated, which is the honest status. Landing page. https://openstrap.github.io/edge/ served a page titled "Edge — Legal" — the repo's own homepage link pointed at the privacy policy. Adds a real landing page at docs/index.html (screenshots, both install paths, what the app is and what it deliberately does not claim) and moves the legal index to docs/legal.html, which every legal page now links. Reuses the existing CSS tokens; no framework, no build step. Contributor on-ramp. 53 forks, one regular outside contributor, and no CONTRIBUTING, SECURITY, issue templates or funding config. Adds: - CONTRIBUTING.md, leading with the thing that actually blocks people: which of the three repos a change belongs in. Plus the two rules that matter — never fabricate a number when the input is missing, and cite the published method — and the honest ceilings (PRV not ECG HRV, relative-only SpO2/temp) that read like bugs but are not. - SECURITY.md for private disclosure, scoped to where the data actually is: the phone, the BLE link, the local database. Not a cloud backend, because there isn't one. - Two issue forms. The second, "a metric looks wrong", exists because "it doesn't match the WHOOP app" is the most common report and is not by itself a bug — the form says so up front and asks for the kind of wrong instead. DONATE.md carries the BTC and EVM addresses, with FUNDING.yml pointing at it (GitHub's config takes URLs, not wallet addresses). It says plainly that nothing is gated behind paying and that bug reports from real bands are worth more — which is true, since otherwise there is exactly one person's physiology in the test data. --- .github/FUNDING.yml | 4 + .github/ISSUE_TEMPLATE/bug_report.yml | 90 ++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 25 ++++ .github/ISSUE_TEMPLATE/wrong_number.yml | 69 +++++++++ CONTRIBUTING.md | 116 +++++++++++++++ DONATE.md | 46 ++++++ README.md | 43 +++++- SECURITY.md | 52 +++++++ docs/index.html | 176 ++++++++++++++++++++++ docs/legal.html | 1 + docs/notice.html | 1 + docs/privacy.html | 1 + docs/style.css | 185 ++++++++++++++++++++++++ docs/terms.html | 1 + 14 files changed, 805 insertions(+), 5 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/wrong_number.yml create mode 100644 CONTRIBUTING.md create mode 100644 DONATE.md create mode 100644 SECURITY.md create mode 100644 docs/index.html diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..7f54a51a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# GitHub's funding config only accepts URLs, not raw wallet addresses, so the +# "Sponsor" button points at DONATE.md where the BTC/EVM addresses live. +custom: + - https://github.com/OpenStrap/edge/blob/main/DONATE.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..3cd6bd6d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,90 @@ +name: Bug report +description: Something in the app is broken or wrong +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting. A couple of things make bugs much faster to fix: + the app version, and whether the number is *wrong* or *missing* — those + are usually different causes. + + - type: textarea + id: what + attributes: + label: What happened? + description: What you saw, and what you expected instead. + placeholder: | + Readiness showed "—" all morning even though the band synced overnight. + I expected a score by the time I woke up. + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: If you can. "It just happens every morning" is a valid answer. + placeholder: | + 1. Wear the band overnight + 2. Open the app in the morning + 3. Today screen shows "—" + validations: + required: false + + - type: input + id: version + attributes: + label: App version + description: Profile → scroll to the bottom, or the release you installed. + placeholder: "0.9.20" + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Platform + options: + - iOS (TestFlight) + - iOS (sideloaded IPA) + - Android (APK) + validations: + required: true + + - type: input + id: device + attributes: + label: Phone model and OS version + placeholder: "Pixel 8, Android 15 / iPhone 15 Pro, iOS 18.5" + validations: + required: false + + - type: dropdown + id: band + attributes: + label: Which band? + options: + - WHOOP 4.0 + - WHOOP 5.0 / MG (experimental — expect breakage) + - Not sure + validations: + required: true + + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Very helpful for anything visual. Redact whatever you want to. + validations: + required: false + + - type: checkboxes + id: checks + attributes: + label: Quick checks + options: + - label: The official WHOOP app is not also connected to this band + required: false + - label: I've searched existing issues for this + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..5eec08b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,25 @@ +blank_issues_enabled: true +contact_links: + - name: Protocol / byte-level finding + url: https://github.com/OpenStrap/protocol/issues/new + about: >- + A new record type, opcode, event, or a field we decode wrongly. Those live + in the protocol repo, not here. + + - name: A new metric, or how one is computed + url: https://github.com/OpenStrap/analytics/issues/new + about: >- + The math lives in the analytics repo. Open it there if you're proposing a + method rather than reporting an app bug. + + - name: Questions and general discussion + url: https://github.com/OpenStrap/edge/discussions + about: >- + Not sure whether something's a bug? Want to ask how a metric works, or + show what you built? Start here. + + - name: Installing on iOS + url: https://github.com/OpenStrap/edge/blob/main/guides/IOS_SIDELOAD.md + about: >- + Install trouble is usually covered by the guide — TestFlight is the easy + path now. diff --git a/.github/ISSUE_TEMPLATE/wrong_number.yml b/.github/ISSUE_TEMPLATE/wrong_number.yml new file mode 100644 index 00000000..56b87195 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/wrong_number.yml @@ -0,0 +1,69 @@ +name: A metric looks wrong +description: A number is displayed, but you think it's incorrect +labels: ["analytics", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Worth saying up front, so nobody wastes time: **OpenStrap's numbers are + not meant to match WHOOP's.** Different algorithms, published methods, + computed from a reverse-engineered byte stream. "It doesn't match the + WHOOP app" on its own isn't a bug. + + What *is* a bug: a number that's physiologically implausible, one that + contradicts the app's own raw data, one that jumps around without + cause, or one that's confidently wrong when the underlying data is + clearly missing. + + - type: input + id: metric + attributes: + label: Which metric? + placeholder: "Readiness / RHR / Sleep stages / Strain / Steps / …" + validations: + required: true + + - type: textarea + id: shown + attributes: + label: What it showed, and why you think it's wrong + description: >- + Include the value, the day, and what you'd have expected. If another + screen in the app disagrees with it, that's a strong signal — say so. + placeholder: | + Readiness 100 after about 10 minutes of wear. There's no overnight data + yet, so it shouldn't be able to produce a confident score at all. + validations: + required: true + + - type: dropdown + id: kind + attributes: + label: What kind of wrong? + options: + - Physiologically implausible (impossible value) + - Contradicts other screens in this app + - Confident number where the data is missing (should show "—") + - Shows "—" where there clearly is data + - Jumps or changes without new data + - Other + validations: + required: true + + - type: input + id: version + attributes: + label: App version + placeholder: "0.9.20" + validations: + required: true + + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: >- + The metric screen plus its trend view is ideal. Redact anything you'd + rather not share — we don't need your actual health data to debug this. + validations: + required: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a1f22989 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,116 @@ +# Contributing + +Thanks for looking. This is a small project and PRs genuinely get read. + +## Which repo does my change go in? + +This is the one thing worth getting right before you start, because it decides +where the code lives. OpenStrap is three packages and the split is strict: + +| Your change | Repo | +|---|---| +| A new record type, opcode, event, or anything about the bytes on the wire | [**protocol**](https://github.com/OpenStrap/protocol) | +| A new metric, or a change to how an existing number is computed | [**analytics**](https://github.com/OpenStrap/analytics) | +| Bluetooth reliability, storage, background sync, UI, anything app-shaped | [**edge**](https://github.com/OpenStrap/edge) (here) | + +If you're not sure, open an issue first and ask — it's cheaper than moving code +between repos afterwards. + +## Ground rules + +**Never fabricate a number.** If an input isn't there, the metric returns +`null`, not a plausible-looking guess. Every metric carries a confidence and a +tier (`AUTH` / `HIGH` / `ESTIMATE` / `RELATIVE`), and those need to stay honest. +A metric that quietly invents a value when the data is missing is worse than no +metric. + +**Cite the method.** Anything in analytics implements a published, peer-reviewed +algorithm, and the citation goes in a comment next to the code. If nothing in +the literature fits what you want to do, that's fine — mark it `ESTIMATE`, give +it low confidence, and say so. Don't invent constants and present them as +science. + +**Some ceilings are real, not bugs.** HRV here is PRV derived from 1 Hz beat +timing. Deep sleep is a low-confidence HR-flatness overlay. SpO2 and skin +temperature are relative ADC values, never absolute. These are properties of +what the band actually hands over. Please don't "fix" them by making the output +look more confident than the input justifies. + +**Bump `kAlgoVersion`** (in `lib/compute/derivation_engine.dart`) whenever a +change alters any analytics output. Stored day results are versioned and +immutable; without a bump, devices keep serving stale values. If your bump's +changelog entry cites an analytics change, check that the pinned analytics SHA +in `pubspec.yaml` actually contains it — that mismatch has shipped a bug before. + +**Put logic in the pure policy classes.** Bluetooth decisions belong in +`lib/ble/ble_state.dart` or `lib/sync/sync_policy.dart` as small, testable +classes; the engine wires them together and the policies decide. That's what +makes any of this testable without a band on your wrist. + +## Running it + +```bash +git clone https://github.com/OpenStrap/edge.git +cd edge +cp .env.example .env +flutter pub get +flutter run --dart-define-from-file=.env +``` + +Quit the official WHOOP app before pairing — Bluetooth only lets one app own the +band at a time. + +For local work across all three packages at once, create a `pubspec_overrides.yaml` +(it's gitignored) pointing at your sibling checkouts: + +```yaml +dependency_overrides: + openstrap_protocol: + path: ../protocol + openstrap_analytics: + path: ../analytics +``` + +## Tests + +```bash +flutter analyze +flutter test --concurrency=1 +``` + +`--concurrency=1` matters: the suite runs against real database files and +parallel workers race on them. + +A few tests replay `whoop_hist.jsonl`, a real band capture kept *beside* the +repo rather than committed to it. If you don't have it those tests skip +automatically — that's expected, and CI runs the same way. Everything else runs +everywhere. + +CI runs `flutter analyze` and the full suite on every PR. Please make sure both +are green locally first. + +## Pull requests + +- Branch off `main`. One logical change per PR. +- Explain *why*, not just what. If it fixes an issue, link it. +- If it changes anything a user sees, say what it looked like before and after. +- Protocol changes: say how you verified it. "Decoded N real records off my own + band and the values were plausible" is a perfectly good answer, and honestly + more useful than a unit test alone. +- No `Co-Authored-By` trailers. + +## Reporting protocol findings + +If you've worked out a field, an opcode, or an event we don't decode yet, that's +one of the most valuable things you can contribute. Open an issue in +[protocol](https://github.com/OpenStrap/protocol/issues) with the raw bytes, what +you think the field is, and how you convinced yourself. A lot of the current +event table is empirical guesswork by one person — more eyes genuinely helps. + +## A note on scope + +This project talks to hardware people already own, using their own data, on +their own device. Please keep contributions within that: no scraping WHOOP's +services, no redistributing their code, firmware, or assets, and no vendored +material from other reverse-engineering projects whose licences don't permit it. +Facts about a protocol are fine. Someone else's source code is not. diff --git a/DONATE.md b/DONATE.md new file mode 100644 index 00000000..3ae09b4b --- /dev/null +++ b/DONATE.md @@ -0,0 +1,46 @@ +# Support OpenStrap + +OpenStrap is free, MIT-licensed, and has no company, no subscription, and no +revenue behind it. It exists because a perfectly good sensor turned into a +bracelet and that seemed like a stupid reason to throw hardware away. + +If it gave your band a second life and you'd like to chip in, these are the only +addresses. There is no other donation channel, no token, and nothing for sale. + +### Bitcoin + +``` +bc1qvtcch38dcwp967ar764uu6eetw7tf907844wfq +``` + +### EVM — Ethereum, Base, Arbitrum, Optimism, Polygon + +``` +0x8310C89393366b7eBCD47ABa82e1dfB5ECeFFbD9 +``` + +--- + +**Please don't feel obliged.** The genuinely valuable contributions are free: + +- **Open an issue** when a number looks wrong. Bug reports from real bands on + real wrists are worth more than money — there's only one person's physiology + in the test data otherwise. +- **Decode something.** A lot of the event table is empirical guesswork. If you + work out a field we don't understand, that helps everyone with one of these + bands. See [protocol](https://github.com/OpenStrap/protocol/issues). +- **Tell someone** whose strap is in a drawer. + +### What donations do and don't buy + +They don't buy priority, features, or support. This isn't a paid product and +turning it into one would defeat the point. What they realistically cover is the +Apple Developer Program membership that keeps the TestFlight build alive, test +hardware (a second band is the main thing that would speed up WHOOP 5 support), +and coffee. + +Nothing is gated behind paying, and nothing ever will be. + +--- + +*Not affiliated with, endorsed by, or connected to WHOOP.* diff --git a/README.md b/README.md index 4970c997..eced4b15 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,28 @@ An app that makes a WHOOP 4.0 useful without a WHOOP subscription. Connects to the band over Bluetooth, computes everything on your phone locally iOS and Android. +[![test](https://github.com/OpenStrap/edge/actions/workflows/test.yml/badge.svg)](https://github.com/OpenStrap/edge/actions/workflows/test.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![TestFlight](https://img.shields.io/badge/iOS-TestFlight-0D96F6?logo=apple&logoColor=white)](https://testflight.apple.com/join/2BVSwq65) +[![APK](https://img.shields.io/github/v/release/OpenStrap/edge?label=Android%20APK&logo=android&logoColor=white)](https://github.com/OpenStrap/edge/releases/latest) + > Not affiliated with WHOOP. Not a clone of their app or their scores — see below. image +## Install + +| | | +|---|---| +| **iOS** | **[Join the TestFlight beta →](https://testflight.apple.com/join/2BVSwq65)** — normal TestFlight install, no sideloading, no computer needed. | +| **Android** | **[Download the APK →](https://github.com/OpenStrap/edge/releases/latest)** — allow installs from unknown sources and open it. | + +Quit the official WHOOP app before you pair. Bluetooth only lets one app own the +band at a time. + +Prefer to sideload the unsigned IPA instead of using TestFlight? That still +works — see [`guides/IOS_SIDELOAD.md`](guides/IOS_SIDELOAD.md). + ## What made me build this app My subscription lapsed and a perfectly good sensor turned into a bracelet. The hardware @@ -65,11 +83,11 @@ shortcuts. "usually," not "always." - Metrics are approximations off published research — not medical-grade, not validated against a lab, don't treat any of it as a diagnosis. -- Sideload only right now — not on the App Store or Play Store. You're installing an APK - or an unsigned IPA straight off Releases. Android's just "allow unknown sources" and - you're done. iOS needs one extra tool — see - [`guides/IOS_SIDELOAD.md`](guides/IOS_SIDELOAD.md) if you just want the app and aren't - planning to build it yourself. +- Not on the App Store or Play Store yet. iOS is a public TestFlight beta, which is a + normal install but still a beta; Android is an APK straight off Releases. +- WHOOP 5.0 / MG support is in progress and **experimental** — the band is detected and + spoken to, but it hasn't been validated against real 5.0 hardware. WHOOP 4.0 is the + only one that's actually tested. ## Run it @@ -139,3 +157,18 @@ Found something broken? Open an issue. Found something broken and fixed it? Even send the PR. Protocol-level stuff (new record types, opcodes) belongs in the protocol repo, metric/formula changes belong in analytics, anything about the app itself — Bluetooth, storage, UI — belongs here. + +[**CONTRIBUTING.md**](CONTRIBUTING.md) has the details: which repo a change belongs in, +how to run the three packages together locally, and the two rules that matter most — +never fabricate a number when the data isn't there, and cite the published method you're +implementing. + +Security problems shouldn't go in a public issue — see [SECURITY.md](SECURITY.md) for +private reporting. + +## Support the work + +Free, MIT, no company behind it. If it gave your band a second life: +[**DONATE.md**](DONATE.md) has BTC and EVM addresses. Bug reports from real bands are +worth more than money, though — there's only one person's physiology in the test data +otherwise. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..febfbc5f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,52 @@ +# 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/edge/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 everything +on-device. There's no account and no server holding your health data. The +optional companion worker exists for legacy import, an update pointer, and two +opt-in features that are off by default and compiled out of store builds +entirely. See [PRIVACY.md](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. diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 00000000..f9da6fa7 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,176 @@ + + + + + +OpenStrap Edge — use your WHOOP 4.0 without a subscription + + + + + + + + + + +
+
+ Edge + +
+ +
+
+

Your WHOOP 4.0 doesn’t stop working when the subscription does.

+

+ Only the app goes dark. Edge pairs with the band over Bluetooth, decodes what it + recorded, and works out your sleep, recovery, strain and the rest — + entirely on your phone. No account, no cloud, nothing to pay. +

+ + + +

+ Free and MIT licensed. Not affiliated with WHOOP. + Tested on WHOOP 4.0 — 5.0 / MG support is experimental. +

+
+ +
+
+ The Today screen, showing a readiness ring and the day's headline metrics +
Today
+
+
+ The Sleep screen, showing a hypnogram and sleep stage breakdown +
Sleep
+
+
+ The Heart screen, showing heart rate and HRV trends +
Heart
+
+
+ The weekly Recap screen +
Recap
+
+
+

Every screenshot is real output from a WHOOP 4.0.

+ +
+
+

It runs on your phone

+

+ The band talks to the app, the app decodes the bytes and computes every metric + locally, and the results are stored on the device. There’s no server that + sees your health data — that isn’t a setting, it’s the + architecture. +

+
+
+

Published methods, cited

+

+ Banister TRIMP, Cole–Kripke, Lomb–Scargle, van Hees and friends. + Nothing invented, no neural net guessing at WHOOP’s formulas. You can go + read the paper and decide whether you trust the number. +

+
+
+

It admits what it doesn’t know

+

+ Every metric carries a confidence and a tier. When the data isn’t there + you get a dash, not a plausible-looking guess. SpO2 and skin + temperature are relative-only, because that’s what the band actually + gives up. +

+
+
+

Not a WHOOP clone

+

+ Different algorithms, different numbers. They have years of research and a + team; this is textbook methods over a reverse-engineered byte stream. It trends + correctly and it’ll tell you when you’re under-recovered. It + isn’t their secret sauce and never claims to be. +

+
+
+ +
+

Before you install

+
    +
  • Quit the official WHOOP app before pairing. Bluetooth only lets one app own the band at a time.
  • +
  • Pick one and stay on it. A firmware push from the official app could change the records this depends on, and there’s no fixing that from here.
  • +
  • Not medical-grade. These are approximations from published research, not validated against a lab. Nothing here is a diagnosis.
  • +
  • There are bugs. It’s a beta built by one person. Open an issue when something looks wrong — that’s genuinely the most useful thing you can do.
  • +
+
+ +
+

How it’s put together

+

band → Bluetooth → protocol decoder → local storage → analytics → the UI

+ +
+ +
+

Support the work

+

+ Free, MIT licensed, no company behind it. Bug reports from real bands are worth + more than money — but if you’d like to chip in: +

+
+
BTC
+
bc1qvtcch38dcwp967ar764uu6eetw7tf907844wfq
+
EVM
+
0x8310C89393366b7eBCD47ABa82e1dfB5ECeFFbD9
+
+

+ Nothing is gated behind paying, and nothing ever will be. +

+
+
+ +
+

+ GitHub · + Legal · + Privacy · + Notice +

+

+ Not affiliated with, endorsed by, or connected to WHOOP, Inc. + “WHOOP” is their trademark, used only to say which device this talks to. + MIT licensed. +

+
+
+ + diff --git a/docs/legal.html b/docs/legal.html index 9a68c7d4..8720e7fd 100644 --- a/docs/legal.html +++ b/docs/legal.html @@ -12,6 +12,7 @@
Edge