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
1 change: 1 addition & 0 deletions lib/openstrap_protocol.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export 'src/gen5_records.dart'
Gen5ImuBuffer,
Gen5PpgWaveform,
Gen5RecordDecoder,
Gen5SleepState,
Gen5V18Decoder,
Gen5V20Decoder,
Gen5V21Decoder,
Expand Down
107 changes: 78 additions & 29 deletions lib/src/gen5_records.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,30 @@ abstract class Gen5HistoricalRecord {
// ── v18 — per-second biometric summary (the gen5 analogue of gen4's v24;
// this is the record that actually ships as gen5's "1 Hz" history). ────────

/// The band's own coarse wake/sleep state, from bits 4-5 of
/// [Gen5HistorySample.sleepStateByte] (frame-abs 81).
///
/// Two things to know before consuming it:
///
/// - **It is an envelope, not a stage.** Deep, light and REM all read as
/// [sleep], so it carries no in-sleep stage information.
/// - **[sleep] lags true onset** by roughly ten minutes — the band wants a
/// sustained stretch of stillness before it commits.
enum Gen5SleepState {
/// Nibble 0. Awake/active — the highest-motion, highest-HR state.
wake,

/// Nibble 1. Settling: still, but not yet declared asleep. The only path
/// from [wake] to [sleep].
still,

/// Nibble 2. The band has declared sleep. Lowest motion, lowest HR.
sleep,

/// Nibble 3. Out of bed / arousal. Reachable only from [sleep].
up,
}

/// Decoded gen5 v18 historical record. Field confidence/status is annotated
/// per-field below — several fields have OPEN semantic disagreements between
/// the two reference implementations (whoop-rs vs noop) that could not be
Expand All @@ -120,23 +144,37 @@ class Gen5HistorySample extends Gen5HistoricalRecord {
final int cardiacFlags;

/// @ inner[28] (frame-abs 36). bit7 = HR/RR-valid this second (gates
/// whether [heartRateAlt] should be trusted); bit4 never observed set.
/// whether [heartRateAlt] should be trusted); the low 4 bits are a separate
/// small field — see [hrQualityCounter].
///
/// NOT the low half of a fixed-point heart rate. **bit4 is never set** — 0
/// of 2,663,358 records across two bands — and a fractional byte cannot
/// have a structurally dead bit. The low 4 bits are a separate field,
/// uniform over 0..15; bits 5-7 are flags.
final int hrQualityFlags;

/// Duplicate HR @ inner[29] (frame-abs 37), ~99.6% match to [heartRate] on
/// the reference corpus. Trust only when `hrQualityFlags & 0x80 != 0`.
/// Low 4 bits of [hrQualityFlags] — a small field, uniform over 0..15,
/// independent of bit7. Purpose unknown; exposed for diagnostics only.
int get hrQualityCounter => hrQualityFlags & 0x0F;

/// Second heart-rate byte @ inner[29] (frame-abs 37).
///
/// Not the near-duplicate of [heartRate] this was previously documented as:
/// it agrees 58-64% of the time, rising to 67-75% when
/// [hrRrValidThisSecond]. The gate is real, but even gated this is a
/// corroboration signal — do not substitute it for [heartRate].
final int heartRateAlt;

/// @ inner[30:32] (frame-abs 38). Meaning UNPINNED — exposed raw, do not
/// consume as a decoded value yet.
final int rrPacked;

/// @ inner[32] (frame-abs 40). DISPUTED: whoop-rs calls this
/// "signal_quality" and gates an HR-anomaly confidence check on `>=192`;
/// noop calls the SAME offset "cardiac_status" and treats it as
/// raw/uninterpreted. Neither claim is cross-validated against ground
/// truth — exposed raw ONLY. Do NOT wire an HR-anomaly gate off this byte
/// until validated against a labelled dataset.
/// @ inner[32] (frame-abs 40). Meaning still unpinned. whoop-rs calls it
/// "signal_quality" and gates an HR-anomaly check on `>=192` — that gate
/// passes 96.7% of records and its rejections don't track [sleepState]
/// consistently between bands, so it is not doing what it looks like.
///
/// Exposed raw ONLY. Do NOT wire an HR-anomaly gate off this byte.
final int cardiacStatusRaw;

/// Gravity-removed motion magnitude (g) @ inner[33:37] f32 LE (frame-abs
Expand Down Expand Up @@ -189,22 +227,19 @@ class Gen5HistorySample extends Gen5HistoricalRecord {
/// Raw @ inner[73] (frame-abs 81). Packed:
/// bits 0-1: on-wrist
/// bits 2-3: wake_quality
/// bits 4-5: sleep_state — ORDERING UNRESOLVED (whoop-rs's doc comment
/// says "0 still/1 wake"; noop glosses the same shift "0 wake/1 still"
/// in one place. A real fixture's value here was 0, which is
/// ambiguous between both orderings and could not disambiguate this
/// from bytes alone.) Exposed as the raw byte ONLY — do not decode the
/// sleep_state nibble into an enum until a labelled-sleep capture
/// resolves the ordering; wiring the wrong ordering into a sleep
/// stager would silently corrupt every downstream sleep metric.
/// bits 4-5: sleep_state — 0 wake / 1 still / 2 sleep / 3 up. Prefer
/// [sleepState] over reading the nibble yourself. whoop-rs's
/// "0 still / 1 wake" is the wrong way round.
final int sleepStateByte;

/// @ inner[74] (frame-abs 82). EXPERIMENTAL candidate SpO2% — noop treats
/// this as instrumentation-only with cross-device evidence split, never a
/// shipped metric (whoop-rs's `physio-algo` treats it as legitimate; noop's
/// caution is trusted here per §1.7 — larger, multi-strap corpus). Use
/// [spo2Candidate] for the gated (70-100) getter; never present this
/// alongside gen4's real red/IR SpO2 as an equivalent metric.
/// @ inner[74] (frame-abs 82). **NOT SpO2** — the name is kept only because
/// removing it breaks the API. Treat it as an opaque sleep-gated byte.
///
/// It reads 0 in 99% of records and is *identically* zero unless
/// [sleepState] is [Gen5SleepState.sleep], where it fires on 2.4% of
/// records. A blood-oxygen channel does not switch itself off while the
/// band is worn. The values that do appear cluster at 95-99, which is what
/// makes the byte look like SpO2 in the first place.
final int spo2CandidateRaw;

/// @ inner[98] (frame-abs 106). Proven NOT the high half of a u16 with
Expand Down Expand Up @@ -262,10 +297,20 @@ class Gen5HistorySample extends Gen5HistoricalRecord {
bool get hrRrValidThisSecond => (hrQualityFlags & 0x80) != 0;

/// [heartRateAlt] gated on [hrRrValidThisSecond]; null when unconfirmed.
///
/// Note that "confirmed" still only means 67-75% agreement with
/// [heartRate] — see [heartRateAlt]. This is a corroboration signal, not a
/// substitute HR.
int? get trustedHeartRateAlt => hrRrValidThisSecond ? heartRateAlt : null;

/// EXPERIMENTAL candidate SpO2%, gated 70-100 per §1.7; null otherwise. This
/// is NOT gen4's real dual-wavelength SpO2 — never surface it as equivalent.
/// **Do not use — frame-abs 82 is not SpO2.** The byte is zero in 99% of
/// records and nonzero *only* while [sleepState] is [Gen5SleepState.sleep],
/// so this getter surfaces a plausible-looking 95-99 on 0.6% of records and
/// null on the rest. See [spo2CandidateRaw] for the measurements.
@Deprecated(
'frame-abs 82 is not SpO2: zero in 99% of records and nonzero only '
'during band-declared sleep. Read spo2CandidateRaw if you need the byte.',
)
int? get spo2Candidate => (spo2CandidateRaw >= 70 && spo2CandidateRaw <= 100)
? spo2CandidateRaw
: null;
Expand All @@ -275,16 +320,20 @@ class Gen5HistorySample extends Gen5HistoricalRecord {
/// amplitude reading of 128 on each channel.
bool get isOpticalAmpSentinel => opticalAmpA == 128 && opticalAmpB == 128;

/// bits 0-1 of [sleepStateByte] — the one sub-field of that byte NOT under
/// active ordering dispute.
/// bits 0-1 of [sleepStateByte].
int get onWristRaw => sleepStateByte & 0x03;

/// bits 2-3 of [sleepStateByte].
int get wakeQualityRaw => (sleepStateByte >> 2) & 0x03;

/// bits 4-5 of [sleepStateByte] — RAW ONLY. See [sleepStateByte]'s doc for
/// why this is deliberately not decoded into a named sleep-state enum.
/// bits 4-5 of [sleepStateByte], raw. Prefer [sleepState].
int get sleepStateRawNibble => (sleepStateByte >> 4) & 0x03;

/// The band's own coarse wake/sleep state. Total over the 2-bit nibble, so
/// never null. **A wake/sleep envelope, not a sleep stage** — see
/// [Gen5SleepState] for the evidence and the limits.
Gen5SleepState get sleepState =>
Gen5SleepState.values[sleepStateRawNibble];
}

/// Minimum inner length to read every v18 field this decoder touches (the
Expand Down
76 changes: 72 additions & 4 deletions test/gen5_historical_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,24 @@ void main() {
test('quality flags + alt HR', () {
expect(sample.hrQualityFlags, 0x8D);
expect(sample.hrRrValidThisSecond, isTrue); // bit7 set
expect(sample.hrQualityCounter, 0x0D); // low 4 bits, separate field
expect(sample.heartRateAlt, 101);
expect(sample.trustedHeartRateAlt, 101);
});

test('frame-abs 36/37 is NOT one fixed-point HR', () {
// Read as one u16 this fixture gives 0x658D/256 = 101.55 bpm against
// heartRate 102 — and the ".55" is just bit7 of byte 36.
//
// bit4 is never set on any observed record, which a fractional byte
// could not manage.
expect(sample.hrQualityFlags & 0x10, 0);

// And even with bit7 set, the second HR byte disagrees with heartRate
// by a full bpm — it is a corroboration signal, not a duplicate.
expect(sample.heartRateAlt, isNot(sample.heartRate));
});

test('motion: gravity is unit magnitude, dynamic accel small', () {
expect(sample.gravityG[0], closeTo(-0.7252, 1e-3));
expect(sample.gravityG[1], closeTo(0.4944, 1e-3));
Expand Down Expand Up @@ -95,14 +109,68 @@ void main() {
});

test('experimental fields exposed raw, not fabricated', () {
// cardiac_status / signal_quality (disputed offset semantics, §1.7):
// raw byte only, no anomaly gate wired off it.
// frame-abs 40: still unnamed, and the whoop-rs `>=192` gate passes
// 96.7% of records, so no anomaly gate is wired off it. 255 is the
// modal value.
expect(sample.cardiacStatusRaw, 255);
// spo2_candidate gated 70..100 — this fixture's raw byte is 0, so the
// gated getter must be null, never a fabricated "0%".
// frame-abs 82 is not SpO2 — zero in 99% of records and nonzero only
// during band-declared sleep. This fixture is awake, so it reads 0.
expect(sample.spo2CandidateRaw, 0);
// ignore: deprecated_member_use_from_same_package
expect(sample.spo2Candidate, isNull);
});

test('band sleep state: this fixture is awake', () {
expect(sample.sleepStateByte, 0);
expect(sample.sleepStateRawNibble, 0);
expect(sample.sleepState, Gen5SleepState.wake);
});
});

group('parseGen5Historical — v18 sleep_state nibble ordering', () {
// Same real v18 fixture, with frame-abs 81 (inner[73]) overridden to each
// of the four nibble values. Ordering (0 wake / 1 still / 2 sleep / 3 up)
// was resolved on 400,000 records from two bands: mean dynamic
// acceleration runs 0.0773 / 0.0255 / 0.0104 / 0.0504 g and median heart
// rate 88 / 76 / 60 / 77 bpm across nibbles 0..3, so nibble 0 is the
// highest-motion, highest-HR state (it cannot be "still") and nibble 2 is
// the lowest of both. whoop-rs's "0 still / 1 wake" is reversed.
final frame = hex(
'aa01740001003fb12f1280733d8401b69f266a66460066025a0265020000000'
'000007b0a8d656463ff0012163cf6a439bf2924fd3ed763fe3e3200aa000000'
'000000000000f7000901f10b0007010c020c000000000000000000000000000'
'00000000000000000000100656f1e1e0000009d61a7c00000003e862817',
);

Gen5HistorySample decodeWithNibble(int nibble) {
final inner = Uint8List.fromList(
parseFrame(frame, profile: BandProfile.gen5)!.inner,
);
// Preserve the low bits (on-wrist, wake_quality) — only bits 4-5 move.
inner[73] = (inner[73] & 0x0F) | (nibble << 4);
return parseGen5Historical(inner) as Gen5HistorySample;
}

test('each nibble maps to its named state', () {
expect(decodeWithNibble(0).sleepState, Gen5SleepState.wake);
expect(decodeWithNibble(1).sleepState, Gen5SleepState.still);
expect(decodeWithNibble(2).sleepState, Gen5SleepState.sleep);
expect(decodeWithNibble(3).sleepState, Gen5SleepState.up);
});

test('the nibble is masked to 2 bits and never overruns the enum', () {
for (int b = 0; b <= 255; b++) {
final inner = Uint8List.fromList(
parseFrame(frame, profile: BandProfile.gen5)!.inner,
);
inner[73] = b;
final s = parseGen5Historical(inner) as Gen5HistorySample;
expect(s.sleepStateRawNibble, (b >> 4) & 0x03);
expect(s.sleepState, Gen5SleepState.values[(b >> 4) & 0x03]);
expect(s.onWristRaw, b & 0x03);
expect(s.wakeQualityRaw, (b >> 2) & 0x03);
}
});
});

group('parseGen5Historical — v26 (real fixture)', () {
Expand Down