Skip to content

Contributing

NoopApp edited this page Jun 10, 2026 · 1 revision

Contributing & Building from Source

Welcome! NOOP is built and maintained by community contributors. This page covers how to help, how to build locally, the safety rules that govern the codebase, and the licensing terms.

Not affiliated with WHOOP, and not a medical device. NOOP reads your own data off your own device; it contains no WHOOP code, firmware, or assets and performs no DRM circumvention. Every derived metric is an approximation and is not clinically validated. See Privacy and Security and the Disclaimer and Legal.


Ground rules before you start

A few principles run through the whole codebase. Internalize them before opening a PR.

  1. Offline by design. There is no server, no telemetry, no account, no network call. A change that phones home — for any reason — does not belong here. Strap data, imports, and computed metrics live in a local SQLite database and never leave the device.

  2. Interoperability, not impersonation. NOOP talks to a strap the user already owns. It does not log into a WHOOP account, bypass a paywall, or ship WHOOP's proprietary code/firmware/assets/logos. Keep all WHOOP references nominative (used only to name the hardware).

  3. Never destructive on the wire. The strap is real hardware on someone's wrist. The app only sends a curated, reversible command set. See The BLE safety contract below.

  4. Transparent math. Analytics are approximations of published methods, documented file by file. No black boxes, no claims of clinical accuracy.

  5. Credit upstream. The protocol work is built on prior community reverse-engineering — johnmiddleton12/my-whoop (WHOOP 4.0) and b-nnett/goose (WHOOP 5.0). Preserve those credits in code comments and documentation.


Ways to contribute

Not all contributions are code. Here's what moves NOOP forward:

Type How
Report bugs File a GitHub issue with reproduction steps, hardware info, and logs (if applicable).
Share strap logs Captured BLE frames help with protocol RE and debugging. See tools/linux-capture/README.md for how to capture on Linux.
Test on real hardware Test PRs and new features on your own strap. Report what works and what doesn't.
Code contributions Protocol decoding, analytics, storage, UI, or platform support. Read the Development section below first.
Documentation Improve README, docs, code comments, or contribute to this wiki. Clear writing is code that scales.
Star the repo Visibility helps the project. It only takes a click.
Donations Optional. See Donations.

Development

Prerequisites

Tool Notes
macOS 13+ Deployment target.
Xcode 15+ (Swift 5.9 toolchain) Provides xcodebuild and the macOS SDK.
XcodeGen 2.45+ Generates Strand.xcodeproj from project.yml. Install via brew install xcodegen.

The packages themselves only need a Swift toolchain — they build and test with plain swift build / swift test, no Xcode project required.

Repository layout

The codebase is split into reusable, cross-platform Swift packages plus a thin platform-specific app layer. The macOS app is the reference implementation; Android ships as a full app under android/, and iOS is an experimental, build-from-source community port.

Strand/
├── project.yml                 # XcodeGen project definition — source of truth for macOS
├── Strand.xcodeproj/           # Generated — do NOT hand-edit (gitignored)
├── Strand/                     # macOS SwiftUI app target (product name: NOOP)
│   ├── App/                    # StrandApp, AppModel, RootView, ContentView
│   ├── BLE/                    # CoreBluetooth manager, frame router, commands, live state
│   ├── Collect/                # Backfiller, Collector, clock correlation
│   ├── Data/                   # Repository, importers, MetricCatalog, settings
│   ├── Screens/                # SwiftUI screens (Today, Sleep, Trends, etc.)
│   ├── MenuBar/                # Menu bar content (glanceable live HR)
│   ├── System/                 # MacActions (lock screen, Shortcuts), ProjectInfo
│   └── Resources/              # Info.plist, Strand.entitlements, AppIcon
├── StrandTests/                # macOS app unit tests
├── Packages/
│   ├── WhoopProtocol/          # BLE frame parsing, CRC, command/event/packet decode
│   ├── WhoopStore/             # GRDB/SQLite persistence, migrations, streams, caches
│   ├── StrandAnalytics/        # HRV / recovery / strain / sleep / correlation math
│   ├── StrandImport/           # WHOOP CSV + Apple Health importers
│   └── StrandDesign/           # SwiftUI design system (palette, components, charts)
├── Tools/
│   └── Backfill/               # `swift run backfill` — re-runs importers
├── tools/
│   └── linux-capture/          # Linux capture workbench (Python/bleak + whoop-decode)
├── Fixtures/                   # Sample WHOOP export for tests
└── android/                    # Android client — full shipped app (Kotlin/Gradle)

Where logic belongs

If your change is about… It belongs in… Notes
Decoding strap bytes, CRC, framing, packet/event types Packages/WhoopProtocol Platform-pure — no CoreBluetooth. Runs in tests/CLI unchanged.
Persisting decoded data, migrations, caches, reads Packages/WhoopStore GRDB/SQLite only.
Computing recovery / strain / HRV / sleep / correlations Packages/StrandAnalytics Pure, database-free analyzers.
Parsing WHOOP CSV or Apple Health exports Packages/StrandImport Header-name-driven CSV; streaming SAX XML.
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.
A screen, sidebar item, menu-bar UI, automation Strand/Screens, Strand/App, Strand/System App layer.
Capturing strap frames on Linux tools/linux-capture Python/bleak capture.

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 unit tests that run without an app, a strap, or CoreBluetooth.

Cross-platform discipline

Every package declares both .iOS(.v16) and .macOS(.v13) so the protocol, storage, analytics, import, and design layers compile and run unmodified on iOS once an app target exists. Framework-specific code must be guarded:

#if canImport(AppKit)
let ns = NSColor(self).usingColorSpace(.sRGB) ?? NSColor(self)
#elseif canImport(UIKit)
let ui = UIColor(self)
#endif

Do not add import AppKit / import UIKit / import CoreBluetooth to any file under Packages/ — that breaks the cross-platform contract. CoreBluetooth lives only in the macOS app's Strand/BLE.


Building locally

macOS (reference implementation)

The Xcode project is generated, not committed. project.yml is the source of truth.

1. Generate the Xcode project

cd /path/to/Strand
xcodegen generate

Re-run this whenever you add/remove source files or edit project.yml.

2. Build

xcodebuild \
  -project Strand.xcodeproj \
  -scheme Strand \
  -destination 'platform=macOS' \
  CODE_SIGNING_ALLOWED=NO \
  build

Notes:

  • The scheme is Strand; the built product is NOOP.appproject.yml sets PRODUCT_NAME: NOOP, bundle id com.noopapp.noop.
  • CODE_SIGNING_ALLOWED=NO skips signing for a fast local loop.
  • Swift Package Manager resolves GRDB.swift (SQLite) and ZIPFoundation (export unzip) automatically on first build.

3. The ad-hoc-signed NOOP.app (for running)

The app is sandboxed and requests Bluetooth + user-selected-file access. To produce a runnable bundle, build without disabling signing:

xcodebuild \
  -project Strand.xcodeproj \
  -scheme Strand \
  -configuration Debug \
  -destination 'platform=macOS' \
  -derivedDataPath build \
  CODE_SIGN_IDENTITY="-" \
  build

The product lands at build/Build/Products/Debug/NOOP.app. You can confirm it is ad-hoc signed:

codesign -dvv build/Build/Products/Debug/NOOP.app
# Signature=adhoc
# Identifier=com.noopapp.noop
# CodeDirectory ... flags=0x2(adhoc)
# TeamIdentifier=not set

Copy the bundle to your Applications folder and launch. On first run, macOS prompts for Bluetooth permission.

4. Development loop

# after editing project.yml or adding/removing files:
xcodegen generate

# fast syntax/type check (no signing, no bundle):
xcodebuild -project Strand.xcodeproj -scheme Strand \
  -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build

# per-package iteration (much faster than the full app):
cd Packages/WhoopProtocol && swift build && swift test
cd Packages/WhoopStore    && swift build && swift test
cd Packages/StrandImport  && swift build && swift test

# run full app tests:
xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' test

You can also open the generated project interactively:

open Strand.xcodeproj

5. Pairing & BLE on macOS

NOOP connects over CoreBluetooth. The central manager is created on the main queue so all delegate callbacks arrive on the main actor. WHOOP 4.0 uses the 61080001-… service family with a CRC8 header; WHOOP 5.0 (the "goose"/MG path) uses the fd4b0001-… family with a CRC16-Modbus header. The strap must be out of range of the official app during initial bonding, and worn/charged to report a non-zero heart rate.

6. Re-importing data into the on-device DB

The on-device SQLite database lives inside the app sandbox container:

~/Library/Containers/com.noopapp.noop/Data/.../whoop.sqlite

Tools/Backfill is a small executable that re-runs the WHOOP CSV / Apple Health import mapping directly against that database — useful after changing importer logic:

cd Tools/Backfill
swift run backfill

Per-package iteration (fastest)

Most contributions live inside one package. Build and test it in isolation — it's far faster than the whole app and needs no strap:

cd Packages/WhoopProtocol && swift build && swift test
cd Packages/WhoopStore     && swift build && swift test
cd Packages/StrandAnalytics && swift build && swift test
cd Packages/StrandImport   && swift build && swift test
cd Packages/StrandDesign   && swift build && swift test

Linux (protocol reverse-engineering)

The pure packages build and test on Linux with the standard Swift toolchain (no Apple frameworks). WhoopProtocol also produces a whoop-decode CLI:

cd Packages/WhoopProtocol
swift build && swift test                 # decoder + tests on Linux
swift build --product whoop-decode        # the CLI → .build/debug/whoop-decode

cd ../../tools/linux-capture
python3 -m unittest -v                    # framing/reassembly tests

Capturing from a real strap on Linux is documented in tools/linux-capture/README.md.

Android (shipped)

Android ships as a full, native client — a separate Kotlin/Gradle module under android/ with its own README and build instructions. Pre-built APKs are published in Releases.

Toolchain: JDK 17, Android Studio (current stable with Android SDK), Gradle. Open the android/ directory in Android Studio, let Gradle sync, and run on a device with Bluetooth.


Code conventions

  • Swift, four-space indent, no trailing whitespace. Match the surrounding file.
  • Public API is intentional. public only what a consumer package or the app actually needs. Types crossing concurrency boundaries are Sendable where it makes sense.
  • Document the "why", and cite sources. Protocol and analytics code carries comments explaining where a fact came from (e.g., "Ported from Goose reverse-engineering"; "Task Force 1996 HRV"; "Karvonen %HRR"). Preserve and extend these citations — they keep the math transparent and the protocol auditable.
  • Pure where possible. WhoopProtocol decode functions, StrandAnalytics analyzers, and FrameRouter are deliberately free of side effects and frameworks so they're unit-testable. Keep new logic in that pure style and let the app layer do the I/O.
  • @MainActor for UI-touching state. FrameRouter and live-state types are main-actor isolated; CBCentralManager is created on .main so delegate callbacks land on the main actor.
  • No anonymous magic. Reach for an existing constant/enum (NoopMetrics, StrandPalette, WhoopCommand, MetricCatalog) before introducing a literal.
  • Validate before you trust. Any data coming off the wire is gated on its checksum and range-checked before it can drive state. New inbound paths follow the same pattern.

The BLE safety contract (read this before touching Bluetooth)

The strap is real hardware on someone's wrist. The Bluetooth path is the highest-stakes code in the repo, and it has hard rules. A PR that violates any of them will not be merged.

1. Never add destructive commands

The app's outbound command set lives in Strand/BLE/Commands.swift as WhoopCommand. It is intentionally a curated subset of the strap's command space. Every command currently in the enum is safe and reversible — toggle realtime HR, read clock / battery / version / data range, run/stop a haptic pattern, arm/read/cancel the firmware alarm, start/stop raw data.

Do not add reboot, firmware/DFU, ship-mode/power-cycle, force-trim, fuel-gauge reset, or any command that can brick, wipe, or permanently alter the device. If you believe a non-trivial command is genuinely needed, open an issue first, justify why it's reversible, and document its payload and on-device verification before any code.

2. CRC-gate everything

Frames are only acted on after both CRCs pass. Outbound frames are built with correct CRCs; inbound frames are rejected if their checksum fails.

  • Outbound: WhoopCommand.frame(seq:payload:) builds the frame with correct CRC8 (for WHOOP 4) or CRC16-Modbus (for WHOOP 5) and zlib CRC32.
  • Inbound: FrameRouter.handle(frame:) rejects any frame whose crcOK == false before it can touch LiveState. Bad bytes never drive state. New inbound paths must do the same.

Never short-circuit a CRC check "to make a capture work". If a real frame fails CRC, the bug is in the framing/decoding, not in the check.

3. Keep the BLE path stable

The connect/bond/offload state machine in Strand/BLE/BLEManager.swift is load-bearing and was hardened against real failure modes. Treat it as stable infrastructure:

  • Don't reorder the connect handshake. Offload is deliberately gated on connectHandshakeDone; SET_CLOCK must precede arming the firmware alarm so the strap RTC is UTC-correct.
  • Don't ENTER high-frequency sync. The app sends exitHighFreqSync defensively on connect to release straps parked there by older builds.
  • Prefer .withoutResponse writes. Use .withResponse only where an ack is genuinely required (e.g., historicalDataResult).
  • Verify on real hardware. Anything that changes what bytes go out, or when, must be tested against an actual strap and the result noted in the PR.

The decode core (WhoopProtocol) and the router (FrameRouter) are pure and unit-tested, so you can iterate on parsing and routing logic with swift test and captured frames without a strap. Reserve on-device testing for the connection/command behavior that genuinely needs it.


Design system

StrandDesign is the single source of visual truth. Every screen composes only its tokens and components. Do not hardcode colors, sizes, fonts, or invent ad-hoc cards.

Color — StrandPalette only

Never write a raw hex value or a system color in a screen. Pull from Packages/StrandDesign/Sources/StrandDesign/Palette.swift. Semantic tokens exist for surfaces (surfaceBase / surfaceRaised / surfaceOverlay / surfaceInset), text (textPrimary / textSecondary / textTertiary), borders (hairline / hairlineStrong), status colors (statusPositive / statusWarning / statusCritical), and the recovery/strain gradients. Use the sampling helpers (recoveryColor, strainColor, sleepStageColor, hrZoneColor) rather than picking a stop by hand.

Type — StrandFont only

From Typography.swift. Use the named scale (title1, title2, headline, body, caption, overline, mono). All live/numeric values use tabular digits — use StrandFont.number(_:), bodyNumber, captionNumber, or display(_:). For ALL-CAPS overline labels, use the Text.strandOverline() helper.

Components — compose, don't reinvent

Build screens from shared pieces: NoopCard, StatTile, ChartCard / ChartFooter, SectionHeader, InsightCard, SegmentedPillControl, SourceBadge, RecoveryRing, StrainGauge, Hypnogram, Sparkline, TrendChart, YearHeatStrip, StatePill.

Spacing and sizing come from NoopMetrics (cardRadius, cardPadding, gap, sectionGap, screenPadding, tileHeight, chartHeight) and animation from StrandMotion (interactive, gentle, hero).

If you find yourself writing a one-off card, gradient, font size, or animation in a screen, stop — either it already exists in StrandDesign, or it should be added there (with a #Preview) and then used. Screens stay thin; the system stays canonical.


How to add things safely

Add a new metric

A "metric" is a named daily/series value that flows from an importer or analyzer into SQLite and out to the UI.

  1. Write the series. An importer (Packages/StrandImport) or analyzer (Packages/StrandAnalytics) produces points; they're persisted via WhoopStore.upsertMetricSeries(_:deviceId:) into the metricSeries table. The series key must match exactly what the catalog expects.
  2. Register it in the catalog. Add a MetricDescriptor row in Strand/Data/MetricCatalog.swift:
    d("resp_rate", "Respiratory Rate", "Recovery", "rpm", "my-whoop", "lungs", 1, nil),
    //  key          title                category     unit   source      sf-symbol   decimals  higherIsBetter
    • key — the exact metricSeries key the importer/analyzer writes.
    • category — one of MetricCatalog.categories (Heart, Recovery, Sleep, Strain, Health).
    • source"my-whoop" (strap/CSV) or "apple-health"; drives the SourceBadge.
    • higherIsBettertrue / false / nil; controls delta tinting.
  3. That's it for the UI. Metric Explorer and Compare are built from the catalog, so a correctly registered metric with data behind it appears automatically.
  4. Add a test. Cover the parse/compute in that package's test suite.

Verify the key in three places before you push: what the importer/analyzer writes, the MetricCatalog key, and any SQL WHERE key = …. Mismatched keys are a known class of bug here.

Add a new screen

  1. Build it from StrandDesign. Compose NoopCard, StatTile, ChartCard, etc.; pull every color/font/size from StrandPalette / StrandFont / NoopMetrics. Use the shared ScreenScaffold for standard screen chrome.
  2. Register it in the sidebar. Strand/App/RootView.swift drives navigation from the NavItem enum:
    • Add a case to NavItem.
    • Add an SF Symbol in the icon switch.
    • Add the case to the detail view-builder switch.
  3. Keep state where it belongs. Read through AppModel / Repository; don't reach into CoreBluetooth or SQLite directly from a view.
  4. Optional features default OFF. Anything that takes a Mac action, fires a notification, or automates behavior is opt-in and toggleable.

Add a new BLE command

Only after re-reading The BLE safety contract.

  1. Confirm it is safe and reversible. If it can brick, wipe, reflash, or permanently alter the strap, it does not go in. No exceptions.
  2. Add the case to WhoopCommand in Strand/BLE/Commands.swift with its on-wire raw value, a label, and a comment documenting the payload, what it does, why it's safe/reversible, and how it was verified on-device.
  3. Add a payload builder if needed (e.g., setAlarmPayload(epochSec:)), keeping the byte layout documented.
  4. Send it through the existing pathBLEManager.send(_:payload:writeType:) — which frames the command (correct CRC) and writes to the command characteristic.
  5. Verify on a real strap and record the result in the PR.

Add a database column or table

Schema lives in Packages/WhoopStore/Sources/WhoopStore/Database.swift as a versioned GRDB DatabaseMigrator (currently through v9).

  • Never edit an existing migration. They've already run on users' databases. Add a new migrator.registerMigration("vN") { db in … } block.
  • The early migrations create the durable decoded-stream tables (hrSample, rrInterval, spo2Sample, skinTempSample, respSample) keyed by (deviceId, ts); later ones add metric caches (sleepSession, dailyMetric, metricSeries). Follow the same shape and naming.
  • Add a MigrationTests case proving the migration applies cleanly on top of the prior version.

Testing

  • Each package owns its tests under Packages/<Name>/Tests/…; run them with swift test. Coverage already includes framing/CRC parity, reassembly, schema, stream decode, store insert/read/migration/prune, the analyzers (HRV, recovery, strain, sleep, correlation, baselines), and the CSV / Apple Health importers.
  • Fixtures/ holds a sample WHOOP export for the import tests.
  • Prefer pure tests. Because WhoopProtocol, StrandAnalytics, and FrameRouter are framework-free, you can cover new decode/routing/math with captured frames and fixtures rather than requiring a strap.
  • StrandTests is the macOS app integration suite (run via xcodebuild … test).

Commit & PR conventions

  • Generated artifacts stay out of git. Strand.xcodeproj/, build/, .build/, *.app, and DerivedData are gitignored; commit project.yml, not the generated project.
  • One concern per PR. Keep a protocol change, a schema migration, and a UI change in separate PRs where practical.
  • Show your verification. For anything on the BLE path, state what you tested on real hardware. For analytics, cite the method and add a test. For UI, confirm it uses only StrandDesign tokens.
  • Anonymous, project-voice. Documentation and comments are written in neutral, third-person project voice. Keep upstream credits (my-whoop, goose, GRDB.swift, ZIPFoundation) intact.
  • No proprietary material. Don't add WHOOP firmware, decompiled app code, logos, or assets. Keep contributions to clean-room interoperability with hardware the user owns.
  • Licensing. By opening a pull request you agree your contribution is licensed under the same PolyForm Noncommercial License 1.0.0 as the rest of NOOP. Forks and personal, non-commercial use are welcome under those terms.

License & legal

NOOP is source-available under the PolyForm Noncommercial License 1.0.0:

  • Free for personal and other non-commercial use.
  • You may read, run, fork, and contribute.
  • Commercial use is not granted by this license.
  • By opening a pull request you agree your contribution is licensed under the same terms.

The license covers NOOP's own original code and docs. Protocol facts (frame layouts, command numbers, byte offsets) are uncopyrightable and free to reuse. Bundled dependencies keep their own licenses (GRDB.swift and ZIPFoundation are MIT — see NOTICE).

NOOP is not affiliated with, endorsed by, or connected to WHOOP, Inc. All references to "WHOOP" are nominative — used only to identify the third-party hardware NOOP interoperates with. See Disclaimer and Legal for full detail.


Roadmap

NOOP's logic already lives in cross-platform packages, so most platform work is app-layer wiring. Today the macOS app is the reference implementation and Android ships as a full app. The items below are planned, experimental, or deferred.

Platforms

  • Windows app (planned). A native Windows BLE stack + UI re-implementation that matches the shared packages' behavior. The protocol facts in WhoopProtocol/Resources/whoop_protocol.json and the framing/CRC rules are language-agnostic, so the wire behavior is portable.

  • Android (shipped). A full native Kotlin/Gradle client lives under android/, re-implementing the same wire protocol against Android's BLE stack. It pairs, offloads, persists and scores on-device, and imports WHOOP / Apple Health / Health Connect. Pre-built APKs are in Releases.

  • iOS (experimental community port). An experimental, build-from-source port in PR #42 — app target + widgets + Live Activity + HealthKit that builds for the iOS simulator. It is build-it-yourself only, not officially maintained or distributed: iOS has no anonymous distribution path (the App Store and TestFlight both require a real Apple Developer identity), which is at odds with NOOP staying anonymous. Every package already declares .iOS(.v16) and guards UI-framework code with #if canImport(UIKit) / AppKit, so the non-UI core compiles for iOS today.

Deferred ideas

These are scoped but intentionally not built yet, so contributors know the direction before investing time:

  • Live PPG scope. Surface the strap's raw optical (PPG) stream as a live waveform/diagnostic view. The raw-data plumbing exists, but a stable, useful live scope is deferred.
  • Steps via IMU. Derive step count on-device from the strap's accelerometer instead of relying on imported Apple Health.
  • Notification-watcher helper. A small, opt-in helper to mirror selected macOS notifications to a haptic cue on the strap. Strictly local, off by default, and bounded.
  • Local AI coach. An on-device, offline assistant that reasons over your own series to produce plain-language guidance. Hard requirement: it must stay local and offline — no cloud inference, no data leaving the device.

Roadmap items don't change the ground rules. Everything above still holds: offline-only, no destructive BLE commands, CRC-gated, design-system-only UI, transparent and clearly-non-clinical math, and credit to the upstream reverse-engineering work.


Contact

Questions, feedback, or bugs? Email thenoopapp@gmail.com or file a GitHub issue.


NOOP is an independent, unofficial, non-commercial interoperability project, not affiliated with, endorsed by, or connected to WHOOP, Inc. See Disclaimer and Legal for full notice.

Clone this wiki locally