-
Notifications
You must be signed in to change notification settings - Fork 645
Protocol
This page documents how NOOP communicates directly with WHOOP straps over Bluetooth Low Energy, how the wire protocol was reverse-engineered, and how to extend the decoder for new packet types or sensors.
Key context: NOOP is a standalone, fully-offline companion for straps you own (WHOOP 4.0 and 5.0/MG). It pairs over BLE, decodes the strap's own data streams on-device, and stores everything locally in SQLite. There is no cloud, no account, and no connection to WHOOP's servers involved.
- Not affiliated with WHOOP. NOOP is an independent project. "WHOOP" is used only to identify the hardware and is nominative fair use.
- Not a medical device. Heart rate, HRV, recovery, strain, sleep, SpO₂, and temperature are local approximations only — not clinically validated and not for medical use.
- Safe subset of commands. NOOP sends only reversible, non-destructive commands; dangerous operations (reboot, firmware load, force-trim, DFU) are deliberately excluded from the codebase so they cannot be sent.
- §1201(f) interoperability exemption. This work is protected under 17 U.S.C. § 1201(f) and comparable provisions in other jurisdictions. It achieves interoperability with the user's own device without circumventing any technological protection measure or bypassing any login/account control.
This reverse-engineering effort builds directly on two community projects:
| Project | Generation | Contribution |
|---|---|---|
johnmiddleton12/my-whoop |
WHOOP 4.0 | GATT service UUID, CRC8/CRC32 frame envelope, command numbers, type-40/43/47 stream layouts |
b-nnett/goose |
WHOOP 5.0 | GATT service UUID, CRC16-Modbus header, static CLIENT_HELLO frame, puffin packet types |
Where code is a direct transcription (e.g., the crc16Modbus function), the source file documents it. Sensor scales and field offsets have been re-verified on real WHOOP 4.0 hardware.
Each WHOOP generation advertises a vendor-specific custom service alongside two standard BLE services:
WHOOP 4.0 — service 61080001-8d6d-82b8-614a-1c8cb0f8dcc6
| Characteristic | UUID | Direction | Purpose |
|---|---|---|---|
| Command write | 61080002-… |
app → strap | send commands |
| Command-response notify | 61080003-… |
strap → app | command replies |
| Event notify | 61080004-… |
strap → app | wrist on/off, double-tap, battery, etc. |
| Data notify | 61080005-… |
strap → app | fragmented frames (history, realtime) |
WHOOP 5.0/MG — service fd4b0001-cce1-4033-93ce-002d5875f58a
| Characteristic | UUID | Purpose |
|---|---|---|
| Command write | fd4b0002-… |
send commands |
| Notify (4 channels) |
fd4b0003, fd4b0004, fd4b0005, fd4b0007
|
responses, events, data |
Standard SIG services (both generations)
| Service | Characteristic | UUID | Notes |
|---|---|---|---|
| Heart Rate (180D) | HR Measurement | 2A37 | HR + R-R intervals; works unbonded at ~1 Hz |
| Battery (180F) | Battery Level | 2A19 | battery percent; works unbonded |
The standard 2A37 Heart Rate Measurement is the reliable HR/R-R source (parsed by StandardHeartRate.parse(_:)) and is treated as ground truth even during custom-stream mapping.
WHOOP 4.0 uses a discovered "one confirmed write" trick: a .withResponse write to the command characteristic triggers just-works bonding with no PIN or pairing UI. NOOP performs this with a benign GET_BATTERY_LEVEL command. Once the write acknowledgement arrives, the link is bonded and the custom notify channels become active.
A key discovery (learned the hard way): didWriteValueFor fires on every .withResponse write, so the connect handshake is gated behind a connectHandshakeDone flag — re-running the handshake mid-offload was found to stop the strap from serving historical data.
WHOOP 5.0/MG requires the link to be bonded before touching the fd4b… service — subscribing to a notify channel without an encrypted link simply stalls indefinitely. NOOP writes the static CLIENT_HELLO frame with response, which triggers just-works bonding transparently on Apple platforms.
Once bonded, the one-shot handshake runs:
-
GET_HELLO_HARVARD(35) — version/identity hello -
GET_ADVERTISING_NAME_HARVARD(76) — advertised device name -
SET_CLOCK(10) — set the strap RTC to UTC with an 8-byte payload[seconds u32 LE][subseconds u32 LE](a wrong-length payload is ack'd but not latched, leaving the RTC lost and breaking history) -
GET_CLOCK(11) — empty payload; read RTC → establishes device↔wall clock correlation used for realtime decoding -
SEND_R10_R11_REALTIME(63) — payload[0x00]to stop the ~2/s type-43 raw flood (this is the only control that persists across reconnect;STOP_RAW_DATAdoes not affect it) -
GET_DATA_RANGE(34) — refresh the strap's stored record range for the liveness watchdog - After ~1.5 s settle, request the first historical offload via
SEND_HISTORICAL_DATA
WHOOP 5.0/MG mirrors this flow on the puffin transport, accepting the same command numbers. The CLIENT_HELLO write is static and fixed (16 bytes, pre-computed CRC16/CRC32).
┌──────┬─────────────┬──────┬───────┬──────┬──────┬──────────┬─────────┐
│ 0xAA │ length u16 │ crc8 │ type │ seq │ cmd │ payload │ crc32 │
│ [0] │ LE [1..3] │ [3] │ [4] │ [5] │ [6] │ [7..] │ LE [..] │
└──────┴─────────────┴──────┴───────┴──────┴──────┴──────────┴─────────┘
└─ crc8 over 2 length bytes ─┘
└─────── crc32 (zlib) over [type][seq][cmd][payload] ────────┘
-
0xAA— Start Of Frame marker -
length—u16little-endian, equalsinner_bytes_count + 4(total frame size on wire =length + 4) -
crc8— table-driven, poly0x07, over only the two length bytes (cheap header integrity check) -
type— packet type (command, response, event, historical data, metadata, etc.) -
seq— sequence/version byte (rolling on commands; doubles as record version on historical packets) -
cmd— command or event number -
crc32— standard zlib CRC-32 (reflected, poly0xEDB88320), u32 LE, over inner bytes
Total frame: 1 (SOF) + 2 (length) + 1 (crc8) + N (inner) + 4 (crc32) = length + 4 bytes
The 5.0 transport swaps the header checksum and shifts the inner record:
┌──────┬────────┬──────────────┬────────┬────────┬───────┬──────┬──────┬──────────┬─────────┐
│ 0xAA │ format │ declLength │ header │ header │ crc16 │ type │ seq │ cmd │ crc32 │
│ [0] │ [1] │ LE u16 [2-4] │ [4-6] │ [4-6] │ LE [6]│ [8] │ [9] │ [10] │ LE [..] │
└──────┴────────┴──────────────┴────────┴────────┴───────┴──────┴──────┴──────────┴─────────┘
└ crc16 over frame[0..6] ┘
└──── crc32 over frame[8 .. declLength+4] ────┘
-
format— always0x01 -
declLength—u16LE; counts the payload plus the 4-byte CRC32 trailer (sopayload_length = declLength − 4) -
header— 2 bytes at[4..6] -
crc16— CRC16-Modbus (poly0xA001, init0xFFFF, reflected) overframe[0..6], stored LE atframe[6..8] -
Inner record — starts at offset 8 (not 4):
type,seq,cmd, payload -
crc32— same zlib algorithm, LE, over payloadframe[8 .. declLength+4)
Total frame: declLength + 8 bytes
The static WHOOP 5.0 CLIENT_HELLO (16 bytes, fully-formed type-35 frame with valid CRC16 and CRC32) is:
AA 01 08 00 00 01 E6 71 23 01 91 01 36 3E 5C 8D
| Algorithm | Poly | Init | Reflected | XOR final | Use |
|---|---|---|---|---|---|
| CRC8 | 0x07 |
0x00 |
no | no | WHOOP 4.0 header (length bytes only) |
| CRC16-Modbus | 0xA001 |
0xFFFF |
yes | no | WHOOP 5.0 header (6 bytes) |
| CRC32 (zlib) | 0xEDB88320 |
0xFFFFFFFF |
yes | 0xFFFFFFFF |
Payload integrity (both generations) |
CRC32 is the only payload integrity guarantee and is enforced: any frame with a CRC32 mismatch is rejected, preventing garbled or hostile peers from forging chunk-end markers and corrupting the trim cursor.
BLE notifications arrive as MTU-sized fragments. The Reassembler accumulates bytes, finds the 0xAA SOF, reads the length field, and emits a complete frame once enough bytes have arrived. All custom-channel notifications are fed through one reassembler per connection before routing to the decoder.
| Type | Name | Direction | Purpose |
|---|---|---|---|
| 35 | COMMAND |
app → strap | send a command |
| 36 | COMMAND_RESPONSE |
strap → app | command reply (battery, clock, version, data range) |
| 37–38 |
PUFFIN_COMMAND / RESPONSE
|
5.0 only | re-framed commands on puffin transport |
| 40 | REALTIME_DATA |
strap → app | live HR + R-R intervals (~1 Hz) |
| 43 | REALTIME_RAW_DATA |
strap → app | raw IMU/optical flood (~2/s, ~1.9 KB/frame) |
| 47 | HISTORICAL_DATA |
strap → app | 14-day biometric store (primary source) |
| 48 | EVENT |
strap → app | wrist on/off, double-tap, battery, alarms, double-tap, etc. |
| 49 | METADATA |
strap → app | chunk boundary + trim cursor for safe offload |
| 50 | CONSOLE_LOGS |
strap → app | plain-text firmware log lines |
| 51–56 | extended | 5.0 extensions | IMU streams, puffin metadata |
Puffin type aliasing: WHOOP 5.0 introduces parallel types (e.g., 38 PUFFIN_COMMAND_RESPONSE, 56 PUFFIN_METADATA) that carry 4.0 semantics on the new transport. To avoid "unknown" packet types, canonicalTypeName aliases them onto their 4.0 equivalents (36, 49, etc.) so the decoder treats them the same.
The type-47 store is a rolling ~14-day biometric history and NOOP's primary metric source. It is re-offloaded every ~15 minutes while connected, mirroring the official app's sync.
SEND_HISTORICAL_DATA([0x00], .withResponse)
↓
HISTORY_START ──→ open chunk, accumulate type-47 records
│
├─ HISTORICAL_DATA … HISTORICAL_DATA … (frames buffered)
│
├─ HISTORY_END(unix, trim) ──→ finishChunk:
│ 1. decode & insert rows into SQLite
│ 2. [if enabled] enqueue raw IMU/optical batches
│ 3. persist strap_trim cursor
│ 4. send HISTORICAL_DATA_RESULT([0x01] + end_data) with response
│ (chunk clears; chunkOpen stays true for repeated ENDs)
│
└─ HISTORY_COMPLETE ──→ close offload session
A chunk is forgotten by the strap only after it is locally durable end-to-end:
decode → await insert(decoded) → await setCursor("strap_trim") → ackTrim
Any error short-circuits before the ack, so an unpersisted chunk is never trimmed. The ack itself is a .withResponse write, so the strap discards the chunk only once the write is confirmed. The strap_trim cursor persists across app restarts, so the next session resumes exactly where the last one stopped — no cloud needed.
MetadataType value |
Name | Meaning |
|---|---|---|
| 1 | HISTORY_START |
offload beginning |
| 2 | HISTORY_END |
chunk boundary; carry the trim cursor — ack to advance |
| 3 | HISTORY_COMPLETE |
offload finished |
The HISTORY_END payload (starting at frame offset 7 on 4.0, or 11 on 5.0) is decoded as:
| Payload offset | Field | Type | Meaning |
|---|---|---|---|
| 0 | unix |
u32 LE |
record time (seconds) |
| 4 | subsec |
u16 LE |
sub-seconds |
| 6 | (unmapped) |
u32 LE |
(reserved) |
| 10 | trim_cursor |
u32 LE |
ack with this to advance the strap's trim |
The 8-byte end_data the ack echoes is the frame's last 8 bytes at the trim-cursor location.
-
Idle watchdog (
backfillIdleTimeoutSeconds = 60): re-armed on every genuine offload frame (types 47/48/49/50) only. If the strap goes silent the session exits and resumes next time via the durable cursor. -
Stuck detector (
StuckStrapDetector): after an offload, if the strap reports records newer than NOOP's frontier and that frontier has been frozen, the detector flagsstrapNeedsRebootand attempts defensive recovery (reset the clock and exit high-freq mode).
The type-43 raw flood is intentionally dropped during offload so it cannot starve chunk acks on the BLE link.
Verified against 762 real device captures. The DSP record at version 24 decodes:
| Offset | Field | Type | Sensor / Meaning |
|---|---|---|---|
| 11 | unix |
u32 | real unix seconds |
| 21 | heart_rate |
u8 | bpm |
| 22 | rr_count |
u8 | # of R-R intervals that follow |
| 23 + 2i | rr[i] |
u16 LE | R-R interval in milliseconds |
| 33 / 35 |
ppg_green / ppg_red_ir
|
u16 | optical ADC counts |
| 40/44/48 | gravity_x/y/z |
f32 LE | accel-derived gravity (g) |
| 55 | skin_contact |
u8 | 0 = off-wrist; capacitive detection |
| 56/60/64 | gravity2_x/y/z |
f32 LE | second accel/gravity triplet |
| 68 / 70 |
spo2_red / spo2_ir
|
u16 | raw ADC; SpO₂ % computed on-device |
| 72 | skin_temp_raw |
u16 | raw ADC; °C computed on-device |
| 74 / 76 / 78 |
ambient / led_drive_1 / led_drive_2
|
u16 | optical configuration |
| 80 | resp_rate_raw |
u16 | raw; respiratory rate computed on-device |
| 82 | signal_quality |
u16 | DSP quality metric |
Versions 5, 7, and 9 are HR/R-R-only records with no DSP sensor block. Version 12 shares the v24 layout. The decoder handles version resolution via a ref chain in the schema.
Real WHOOP 5 hardware emits version 18 (124-byte DSP record) and version 26 (88-byte PPG waveform record) — not a shifted v24 layout.
Version 18 (per-second DSP summary):
-
Offset 15 —
unix(u32 LE): real unix seconds -
Offset 22 —
heart_rate(u8): bpm; verified exact match with live 2A37 ground truth across 96 overlapping timestamps -
Offset 23 —
rr_count(u8): # of valid R-R intervals -
Offset 24 + 2i —
rr[i](u16 LE): R-R in ms; 88% match60000 / mean(R-R) ≈ HR -
Offset 45/49/53 —
gravity_x/y/z(f32 LE): 1.0 g magnitude for 100% of records (v18 has one triplet, not v24's two) -
Offset 73 —
skin_temp_raw(u16): AS6221 sensor, native 7.8125 m°C/LSB; stored raw, divided by 128 for °C on-device
Version 26 (24 Hz optical PPG waveform):
-
Offset 15 —
unix(u32 LE): real seconds -
Offset 21 —
ppg_channel(u8): optical channel index (1–26); strap time-multiplexes 26 channels over ~20 min, never sampling two simultaneously -
Offset 27–74 —
ppg_waveform(24× i16 LE): AC-coupled PPG samples at 24 Hz; autocorrelates to heart rate (lag 14 ≈ 103 bpm, matching v18 HR)
SpO₂ is not recoverable on WHOOP 5 offline — it requires simultaneous red+IR, but the strap time-multiplexes channels and never samples two at once. The raw optical region is left undecoded.
The offload streams ~10 type-47 records per second — that is 10× real-time (a full day ≈ 40 min). This is a firmware pacing property, not link throughput:
- Raising BLE MTU from 23 → 247 bytes: no change (a 104-byte type-47 still streamed at ~10/s)
- Lowering connection interval from 50 ms → 7.5 ms: no change
Matching the official app's bulk sync (24 hours in 1–3 min) would require a different flash-page or bulk-transfer command, not link tuning. For unattended periodic sync, ~10× real-time is adequate and resumable via the trim cursor if interrupted.
REALTIME_RAW_DATA (type 43, "R10/R11") is the strap's high-rate sensor flood, streaming continuously at ~2 packets/second with each packet ~1.9 KB. Two variants exist, distinguished by payload length:
| Payload length | Kind | Contents | Sample rate |
|---|---|---|---|
| 1917 | IMU | 6 axes (accelX/Y/Z, gyroX/Y/Z), 100 samples per axis | ~100 Hz |
| 1921 | Optical | AC-coupled green PPG waveform | ~437 Hz |
-
Accel scale:
1/4096g/LSB (verified sphere-fit: |g| ≈ 0.99 g, 0.0% residual) -
Gyro scale:
2000/32768 = 0.06104deg/s/LSB (full-scale ±2000 dps, verified with 720° rotations) - Axis offsets: accelX@89, accelY@289, accelZ@489, gyroX@692, gyroY@892, gyroZ@1092
- Unmapped region: ~36% of frame (header gap + tail from offset 1292) kept raw — an honest gap, not an invented field
The type-43 flood is expensive:
- BLE airtime: ~2 × 1.9 KB/s dominates the link and starves the historical offload
- Strap flash: keeping raw data on blocks dense biometric retention
The only control that persists across reconnect is SEND_R10_R11_REALTIME (63). Sending it with payload [0x00] stops the flood (verified on-device: 2.1/s → 0/s, persists across reconnect). The STOP_RAW_DATA (82) command does not affect it.
For research, captureRawAccel(seconds:) captures IMU for a bounded window with START_RAW_DATA + TOGGLE_IMU_MODE, then re-disables it. This is opt-in only; the global enableRawCapture toggle defaults off.
EVENT frames carry an EventNumber at frame offset 6 (4.0) or 10 (5.0) and a u32 event_timestamp. Selected frequently-used event types:
| Code | Name | Code | Name | |
|---|---|---|---|---|
| 3 | BATTERY_LEVEL |
46 | RAW_DATA_COLLECTION_ON |
|
| 7 | CHARGING_ON |
47 | RAW_DATA_COLLECTION_OFF |
|
| 8 | CHARGING_OFF |
56 | STRAP_DRIVEN_ALARM_SET |
|
| 9 | WRIST_ON |
57 | STRAP_DRIVEN_ALARM_EXECUTED |
|
| 10 | WRIST_OFF |
58 | APP_DRIVEN_ALARM_EXECUTED |
|
| 13 | RTC_LOST |
60 | HAPTICS_FIRED |
|
| 14 | DOUBLE_TAP |
63 | EXTENDED_BATTERY_INFORMATION |
|
| 17 | TEMPERATURE_LEVEL |
96 | HIGH_FREQ_SYNC_PROMPT |
|
| 23 | BLE_BONDED |
97 | HIGH_FREQ_SYNC_ENABLED |
|
| 33 | BLE_REALTIME_HR_ON |
98 | HIGH_FREQ_SYNC_DISABLED |
|
| 34 | BLE_REALTIME_HR_OFF |
100 | HAPTICS_TERMINATED |
FrameRouter maps physical events to UI: BLE_BONDED confirms bonding, DOUBLE_TAP fires onDoubleTap, WRIST_ON/WRIST_OFF toggle worn state.
The BATTERY_LEVEL event has a fixed decoded layout:
-
soc% = u16@17 / 10(WHOOP 4.0) or direct at payload offset 2 (WHOOP 5.0) -
mV = u16@21(4.0) or u16 LE at payload+4 (5.0) -
charging = u8@26 & 1(4.0) or u8 at payload+8 (5.0)
NOOP exposes a deliberately safe command set in WhoopCommand. Commands are built as frames and written to the command characteristic.
| Code | Command | Payload | Purpose |
|---|---|---|---|
| 1 | LINK_VALID |
— | link keep-alive |
| 3 | TOGGLE_REALTIME_HR |
[0x01] or [0x00]
|
start/stop live HR (type-40) |
| 7 | REPORT_VERSION_INFO |
— | firmware versions |
| 10 | SET_CLOCK |
[secs u32 LE][subsecs u32 LE] |
set strap RTC (UTC) |
| 11 | GET_CLOCK |
empty | read RTC → clock correlation |
| 22 | SEND_HISTORICAL_DATA |
[0x00] |
begin offload of type-47 store |
| 23 | HISTORICAL_DATA_RESULT |
[0x01] + end_data(8) |
ack a HISTORY_END chunk / advance trim |
| 26 | GET_BATTERY_LEVEL |
[0x00] |
battery %; also the bond write |
| 34 | GET_DATA_RANGE |
[0x00] |
strap's stored oldest/newest record range |
| 35 | GET_HELLO_HARVARD |
[0x00] |
identity/version hello |
| 63 | SEND_R10_R11_REALTIME |
[0x00] off / [0x01] on |
real type-43 raw-stream control |
| 66 | SET_ALARM_TIME |
[0x01] + epoch u32 LE + [0x00, 0x00] |
arm firmware alarm |
| 67 | GET_ALARM_TIME |
[0x01] |
read armed alarm |
| 68 | RUN_ALARM |
[0x01] |
app-driven alarm now |
| 69 | DISABLE_ALARM |
[0x01] |
disarm firmware alarm |
| 76 | GET_ADVERTISING_NAME_HARVARD |
[0x00] |
advertised name |
| 79 | RUN_HAPTICS_PATTERN |
[patternId, loops, 0, 0, 0] |
buzz a preset haptic (pattern 2 = alarm buzz) |
| 80 | GET_ALL_HAPTICS_PATTERN |
— | enumerate preset patterns |
| 81 / 82 |
START_RAW_DATA / STOP_RAW_DATA
|
[0x01] |
raw-data collection toggle (note: doesn't control type-43) |
| 96 / 97 |
ENTER_HIGH_FREQ_SYNC / EXIT_HIGH_FREQ_SYNC
|
[0x00] |
high-freq offload mode |
| 98 | GET_EXTENDED_BATTERY_INFO |
— | extended battery info (mV, etc.) |
These exist on the wire but are not exposed by WhoopCommand because they are irreversible:
| Code | Command | Hazard |
|---|---|---|
| 25 | FORCE_TRIM |
discards stored data |
| 29 | REBOOT_STRAP |
reboots |
| 32 | POWER_CYCLE_STRAP |
power-cycles |
| 36–38 | Firmware load | firmware write |
| 45 | ENTER_BLE_DFU |
DFU bootloader |
| 99 | RESET_FUEL_GAUGE |
resets battery fuel gauge |
The WHOOP strap (4.0 and 5.0) exposes these sensors and actuators:
| Sensor / Actuator | Protocol Surface | NOOP Use |
|---|---|---|
| PPG optical (green, red/IR LEDs) | type-47 ppg_green / ppg_red_ir / ambient; type-43 optical variant (~437 Hz) |
HR, SpO₂, respiratory rate (derived on-device) |
| Accelerometer (3-axis) | type-47 gravity_x/y/z (f32); type-43 IMU accelX/Y/Z (~100 Hz, 1/4096 g/LSB) |
gravity, motion detection, HR variability context |
| Gyroscope | type-43 IMU gyroX/Y/Z (±2000 dps @ 0.061 deg/s/LSB) |
motion; optionally captured for research |
| Skin temperature | type-47 skin_temp_raw; event TEMPERATURE_LEVEL
|
derived °C on-device |
| Double-tap (capacitive) | event DOUBLE_TAP → onDoubleTap callback |
user input / app trigger |
| Wrist detection | events WRIST_ON/WRIST_OFF; type-47 skin_contact
|
worn/unworn state |
| Haptic motor |
RUN_HAPTICS_PATTERN / STOP_HAPTICS
|
notifications, alarms |
| Battery | standard 2A19; event BATTERY_LEVEL; command responses |
battery % and voltage (mV) |
Not present: microphone, speaker, GPS, display, cellular. All feedback to the wearer is via the single haptic motor.
The decoder is data-driven: most protocol definition lives in whoop_protocol.json, not in code.
-
Static field on an existing packet type: add an entry to that packet's
fieldsarray inwhoop_protocol.jsonwithoff(byte offset),len,dtype(u8/u16/u32/i16/f32),name,cat(category), optionalenum, optionalnote. The parser picks it up automatically. -
New enum value: add it under
enums.PacketType,enums.EventNumber,enums.CommandNumber, orenums.MetadataType. Names are resolved via the schema. -
Irregular layout (variable R-R count, IMU/optical blocks, per-version records): add a closure to
registerPostHooks()inPostHooks.swiftand reference it via the packet'spostkey. The hook receives(FieldBuilder, frame, length, schema)and writes intofb.parsed. -
New historical record version: add a key under
HISTORICAL_DATA.versionswith the version byte as the key. Use"ref"to inherit another version's layout, or define newfields. -
New durable row: define the struct in
Streams.swift, add it toStreams, and emit it fromextractStreams/extractHistoricalStreams. The GRDB persistence layer lives inWhoopStore. -
New command: add a case to
WhoopCommandinCommands.swiftwith its raw wire value. Keep it reversible and non-destructive.
-
Back every change with a real-device capture. Golden-frame fixtures live in
Tests/WhoopProtocolTests/Resources/(frames.json,golden.json,historical_golden.json, etc.). Parity tests assert the Swift decoder reproduces them exactly. -
Prefer real captures over invented offsets. Unmapped regions are kept raw (
unit: "raw") and documented rather than guessed. - Map WHOOP 5.0 offsets from real captures. The 5.0 inner record starts at offset 8 (not 4). Don't assume a 4.0 field transfers with a +4 offset shift — many do, but some (like version 18 HR) don't. Always verify against a real capture.
| Path | Responsibility |
|---|---|
Packages/WhoopProtocol/Sources/WhoopProtocol/Framing.swift |
CRC8/CRC16/CRC32, verifyFrame, Reassembler
|
Packages/WhoopProtocol/Sources/WhoopProtocol/Interpreter.swift |
parseFrame (4.0 + 5.0), ParsedFrame, field builder |
Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceFamily.swift |
UUID strings, header-CRC kind, CLIENT_HELLO, puffin type aliasing |
Packages/WhoopProtocol/Sources/WhoopProtocol/Schema.swift |
JSON schema model + loadSchema()
|
Packages/WhoopProtocol/Sources/WhoopProtocol/PostHooks.swift |
per-type irregular-field decoders |
Packages/WhoopProtocol/Sources/WhoopProtocol/HistoricalMeta.swift |
classifyHistoricalMeta (START/END/COMPLETE) |
Packages/WhoopProtocol/Sources/WhoopProtocol/Resources/whoop_protocol.json |
canonical enums + packet layouts |
Strand/BLE/BLEManager.swift |
CoreBluetooth transport, bond, connect, backfill orchestration |
Strand/BLE/Commands.swift |
safe WhoopCommand set + frame builder |
Strand/BLE/FrameRouter.swift |
decode → LiveState (UI) |
Strand/BLE/StandardHeartRate.swift |
2A37 HR/R-R parser |
Strand/Collect/Backfiller.swift |
historical-offload state machine + safe-trim invariant |
The WhoopProtocol package never imports CoreBluetooth — it exposes UUIDs as plain strings so the protocol code runs unchanged in tests, CLI tools, and simulators. Only BLEManager turns strings into CBUUIDs.
- Features — what NOOP measures and computes
- How NOOP Works — high-level design and data flow
- Privacy and Security — local storage, no cloud
- Strap Support and Pairing — setup and device compatibility
- Contributing — building and testing the project
- The Science — the methods behind HR/HRV/SpO₂/temperature/respiratory rate
Reverse-engineering credit: johnmiddleton12/my-whoop (WHOOP 4.0) and b-nnett/goose (WHOOP 5.0). This is an independent interoperability project for the user's own device and data; it is not affiliated with WHOOP and is not a medical device.
NOOP is an independent, unofficial, non-commercial interoperability project — not affiliated with, endorsed by, or sponsored by WHOOP, Inc. "WHOOP" is a trademark of WHOOP, Inc., used nominatively. Works only with a device you own; not a medical device; every metric is an approximation, not medical advice. · Privacy and Security · Donations · Releases
Get started
Tutorials
- Tracking a Workout
- Recovery, Strain & Readiness
- Automations
- Breathe & Intervals
- Importing History
- AI Coach
- Widget & Notifications
- Reading Your Sleep
- Explore & Compare
Reference
Project