Skip to content
Open
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
12 changes: 9 additions & 3 deletions lib/openstrap_protocol.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
/// only.
library openstrap_protocol;

// Source 0 — multi-band wire-format profile (gen4 / gen5).
export 'src/band.dart' show DeviceType, GattProfile, BandProfile;

// Source 1 — record decoders.
export 'src/records.dart'
show R24, parseR24, FirmwareAwareR24Decoder, R24DecodeStrategy;
show R24, parseR24, parseGen5Record, FirmwareAwareR24Decoder, R24DecodeStrategy;
export 'src/live.dart'
show
DecodedSample,
Expand All @@ -23,7 +26,7 @@ export 'src/live.dart'
decodeBatch;

// Source 2 — CRC, constants, framing, commands.
export 'src/crc.dart' show crc8, crc32;
export 'src/crc.dart' show crc8, crc32, crc16Modbus;
export 'src/constants.dart';
export 'src/framing.dart'
show Frame, pad4, buildFrame, parseFrame, FrameReassembler;
Expand Down Expand Up @@ -59,7 +62,10 @@ export 'src/commands.dart'
cmdSetAlarmSimple,
cmdRunAlarm,
cmdDisableAlarm,
kDefaultAlarmHaptics;
kDefaultAlarmHaptics,
gen5ClientHello,
cmdGetDataRangeGen5,
cmdSendHistoricalGen5;

// Control-plane parsers (HELLO / EVENT / METADATA / COMMAND_RESPONSE / dispatch).
export 'src/control.dart'
Expand Down
143 changes: 143 additions & 0 deletions lib/src/band.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// band.dart — multi-generation ("multi-band") wire-format profile.
//
// WHOOP 4 (gen4 / "Harvard") and WHOOP 5 (gen5 / "fd4b") are NOT two different
// protocols — gen5 is gen4 in a different envelope. Everything that actually
// differs between generations is captured here so the rest of the stack
// (framing, records, edge BLE) can stay band-agnostic and just carry a
// [BandProfile] around.
//
// Verified deltas (see MULTIBAND_WHOOP5_PORT_PLAN.md):
// • frame header: 4 bytes + crc8 → 8 bytes + crc16-modbus
// • payload CRC: crc32 on BOTH (unchanged)
// • command opcodes: shared, EXCEPT HELLO (gen5 = 0x91)
// • GATT service base: 6108000x-… → fd4b000x-… (same low-nibble map)
//
// PURE Dart — dart:typed_data only.

import 'dart:typed_data';
import 'crc.dart';
import 'constants.dart';

/// Which physical WHOOP generation / wire format a link is speaking.
///
/// gen5 covers the whole fd4b family (WHOOP 5.0 "Goose", "Maverick"/MG,
/// "Puffin" battery pack) — they share one wire format; only packet-level
/// sub-types (Puffin) differ, which is handled above the frame layer.
enum DeviceType { gen4, gen5 }

/// GATT service + characteristic UUIDs for one generation. The low nibble is
/// identical across generations (0001 service, 0002 write, 0003 cmd-from,
/// 0004 events, 0005 data, 0007 memfault); only the 32-bit prefix + 96-bit
/// base suffix change.
class GattProfile {
final String service;
final String cmdTo; // write w/response (app → strap)
final String cmdFrom; // notify command responses (strap → app)
final String events; // notify strap events
final String data; // notify data/history packets
final String memfault;

const GattProfile({
required this.service,
required this.cmdTo,
required this.cmdFrom,
required this.events,
required this.data,
required this.memfault,
});

/// WHOOP 4 — base `6108000x-8d6d-82b8-614a-1c8cb0f8dcc6`.
static const GattProfile gen4 = GattProfile(
service: '61080001-8d6d-82b8-614a-1c8cb0f8dcc6',
cmdTo: '61080002-8d6d-82b8-614a-1c8cb0f8dcc6',
cmdFrom: '61080003-8d6d-82b8-614a-1c8cb0f8dcc6',
events: '61080004-8d6d-82b8-614a-1c8cb0f8dcc6',
data: '61080005-8d6d-82b8-614a-1c8cb0f8dcc6',
memfault: '61080007-8d6d-82b8-614a-1c8cb0f8dcc6',
);

/// WHOOP 5 — base `fd4b000x-cce1-4033-93ce-002d5875f58a`.
static const GattProfile gen5 = GattProfile(
service: 'fd4b0001-cce1-4033-93ce-002d5875f58a',
cmdTo: 'fd4b0002-cce1-4033-93ce-002d5875f58a',
cmdFrom: 'fd4b0003-cce1-4033-93ce-002d5875f58a',
events: 'fd4b0004-cce1-4033-93ce-002d5875f58a',
data: 'fd4b0005-cce1-4033-93ce-002d5875f58a',
memfault: 'fd4b0007-cce1-4033-93ce-002d5875f58a',
);

/// The service-UUID 32-bit prefix used to identify this generation from a
/// scan result (case-insensitive `startsWith`).
String get servicePrefix => service.substring(0, 8);
}

/// Per-generation frame wire-format profile. Immutable; use the [gen4] / [gen5]
/// singletons or [BandProfile.of].
class BandProfile {
final DeviceType type;

/// Header length in bytes before the inner payload (gen4 = 4, gen5 = 8).
final int headerLen;

/// Byte offset of the u16-LE declared-length field within the header
/// (gen4 = 1, gen5 = 2). `declared` counts the padded inner + 4-byte CRC32.
final int sizeFieldOffset;

const BandProfile._(this.type, this.headerLen, this.sizeFieldOffset);

static const BandProfile gen4 = BandProfile._(DeviceType.gen4, 4, 1);
static const BandProfile gen5 = BandProfile._(DeviceType.gen5, 8, 2);

static BandProfile of(DeviceType t) =>
t == DeviceType.gen5 ? gen5 : gen4;

bool get isGen5 => type == DeviceType.gen5;

/// GATT UUIDs for this generation.
GattProfile get gatt => isGen5 ? GattProfile.gen5 : GattProfile.gen4;

/// Read the declared length (padded inner + CRC32) from a frame's header.
/// Caller must ensure `frame.length >= sizeFieldOffset + 2`.
int declaredLen(List<int> frame) =>
frame[sizeFieldOffset] | (frame[sizeFieldOffset + 1] << 8);

/// Total frame length on the wire for a given declared length.
int totalLen(int declared) => headerLen + declared;

/// Validate the header integrity check. gen4 = crc8 over the 2 length bytes
/// at frame[3]; gen5 = crc16-modbus over frame[0:6] at frame[6:8] LE.
bool headerCrcValid(List<int> frame) {
if (!isGen5) {
if (frame.length < 4) return false;
return frame[3] == crc8([frame[1], frame[2]]);
}
if (frame.length < 8) return false;
final want = frame[6] | (frame[7] << 8);
return crc16Modbus(frame.sublist(0, 6)) == want;
}

/// Build the frame header for a given declared length.
/// gen4: `[0xAA][u16 declared LE][crc8]`
/// gen5: `[0xAA][0x01][u16 declared LE][0x00][0x01][crc16modbus LE]`
Uint8List buildHeader(int declared) {
if (!isGen5) {
final h = Uint8List(4);
h[0] = sof;
h[1] = declared & 0xFF;
h[2] = (declared >> 8) & 0xFF;
h[3] = crc8([h[1], h[2]]);
return h;
}
final h = Uint8List(8);
h[0] = sof;
h[1] = 0x01;
h[2] = declared & 0xFF;
h[3] = (declared >> 8) & 0xFF;
h[4] = 0x00;
h[5] = 0x01;
final c = crc16Modbus(h.sublist(0, 6));
h[6] = c & 0xFF;
h[7] = (c >> 8) & 0xFF;
return h;
}
}
52 changes: 44 additions & 8 deletions lib/src/commands.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import 'dart:typed_data';
import 'constants.dart';
import 'framing.dart';
import 'band.dart';

enum WristSelection {
right(0x01),
Expand All @@ -14,21 +15,25 @@ enum WristSelection {
}

/// Build a framed command packet: [type][seq][opcode][payload].
/// [profile] selects the generation's frame envelope (default gen4 = WHOOP 4).
/// The inner bytes are identical across generations — command opcodes are
/// shared — so only the envelope differs.
Uint8List buildCommand(int seq, int opcode,
[List<int> payload = const [0x00]]) {
[List<int> payload = const [0x00], BandProfile profile = BandProfile.gen4]) {
final inner = <int>[
PacketType.command,
seq & 0xFF,
opcode & 0xFF,
...payload
];
return buildFrame(inner);
return buildFrame(inner, profile: profile);
}

/// WHOOP's positive historical-burst result (cmd 0x17).
/// Inner = [0x23][seq][0x17][0x01] + token(8B).
/// `token` is the two 4-byte slices from the HistoryEnd METADATA marker.
Uint8List buildHistoryResultOk(int seq, List<int> token) {
Uint8List buildHistoryResultOk(int seq, List<int> token,
{BandProfile profile = BandProfile.gen4}) {
if (token.length != 8) {
throw ArgumentError('batch token must be 8 bytes, got ${token.length}');
}
Expand All @@ -39,17 +44,19 @@ Uint8List buildHistoryResultOk(int seq, List<int> token) {
revision1,
...token,
];
return buildFrame(inner);
return buildFrame(inner, profile: profile);
}

/// The strap's negative historical-burst result (cmd 0x17).
/// Payload is a single FAILURE result byte (the band only needs the code).
Uint8List buildHistoryResultFail(int seq) =>
buildCommand(seq, Cmd.historicalDataResult, const [0x00]);
Uint8List buildHistoryResultFail(int seq,
{BandProfile profile = BandProfile.gen4}) =>
buildCommand(seq, Cmd.historicalDataResult, const [0x00], profile);

/// Legacy alias used by the app transport.
Uint8List buildBatchAck(int seq, List<int> token) =>
buildHistoryResultOk(seq, token);
Uint8List buildBatchAck(int seq, List<int> token,
{BandProfile profile = BandProfile.gen4}) =>
buildHistoryResultOk(seq, token, profile: profile);

/// The 5-packet INIT handshake (hardware-verified, seq 0..4).
/// buildCommand regenerates these byte-for-byte (protocol test asserts it).
Expand Down Expand Up @@ -292,3 +299,32 @@ Uint8List cmdDisableAlarm(int seq, {int revision = 1}) {
final p = revision == 2 ? const [0x02, 0xFF] : [revision & 0xff];
return buildCommand(seq, Cmd.disableAlarm, p);
}

// ── WHOOP 5 (gen5 / "fd4b") handshake + offload ────────────────────────────
//
// gen5 differs from gen4's 5-packet INIT: the link opens with a single
// CLIENT_HELLO (GET_HELLO = 0x91) written with-response to trigger the
// just-works bond, then the offload is driven by GET_DATA_RANGE (0x22) and
// SEND_HISTORICAL_DATA (0x16) — the SAME opcodes as gen4, but with EMPTY
// payloads (gen4 sends a single 0x00). The HISTORY_END ACK
// ([buildHistoryResultOk]) is byte-structured identically; only the frame
// envelope differs, so pass `profile: BandProfile.gen5`.

/// The gen5 CLIENT_HELLO frame (GET_HELLO = 0x91, payload [0x01]).
///
/// Built through [buildFrame] with the gen5 profile; this reproduces the
/// canonical, hardware-observed 16-byte hello byte-for-byte:
/// `aa 01 08 00 00 01 e6 71 23 01 91 01 36 3e 5c 8d`. A `gen5_test` asserts
/// this equality, which simultaneously validates crc16-modbus + crc32 + the
/// gen5 header layout. Sequence defaults to 1 to match that canonical frame.
Uint8List gen5ClientHello({int seq = 1}) =>
buildCommand(seq, Cmd.getHello, const [0x01], BandProfile.gen5);

/// gen5 GET_DATA_RANGE (0x22) with the EMPTY payload gen5 expects.
Uint8List cmdGetDataRangeGen5(int seq) =>
buildCommand(seq, Cmd.getDataRange, const [], BandProfile.gen5);

/// gen5 SEND_HISTORICAL_DATA (0x16) with the EMPTY payload gen5 expects — the
/// command that starts the flash drain.
Uint8List cmdSendHistoricalGen5(int seq) =>
buildCommand(seq, Cmd.sendHistoricalData, const [], BandProfile.gen5);
20 changes: 20 additions & 0 deletions lib/src/crc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,23 @@ int crc32(List<int> data) {
}
return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF;
}

/// CRC-16/MODBUS (init 0xFFFF, reflected poly 0xA001, no final XOR) over the
/// WHOOP 5 (gen5 / "fd4b") 8-byte frame header bytes [0:6]. WHOOP 4 (gen4)
/// frames use [crc8] for header integrity instead; the payload trailer stays
/// [crc32] on BOTH generations. Verified byte-exact against the gen5 client
/// HELLO frame (header `aa 01 08 00 00 01` → 0x71E6).
int crc16Modbus(List<int> data) {
int crc = 0xFFFF;
for (final b in data) {
crc ^= b & 0xFF;
for (int i = 0; i < 8; i++) {
if ((crc & 1) != 0) {
crc = (crc >> 1) ^ 0xA001;
} else {
crc >>= 1;
}
}
}
return crc & 0xFFFF;
}
Loading