Skip to content

Commit bc7d8d0

Browse files
authored
Merge pull request #54 from DropTabl/feat/mg-ecg-parser
labrador: WHOOP MG identity, R17 parser, R16 recognition
2 parents 202174f + 4aa36fa commit bc7d8d0

9 files changed

Lines changed: 883 additions & 18 deletions

‎lib/openstrap_protocol.dart‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ export 'src/gen5_records.dart'
7979
parseGen5ImuBuffer,
8080
parseGen5Historical,
8181
reconstructSaturatedDeltaWindow;
82+
// WHOOP MG Labrador (filtered ECG, R17) and raw ECG (R16) — see labrador.dart.
83+
export 'src/labrador.dart'
84+
show LabradorR17, LabradorFlags, LabradorUnreadableMask, LabradorR16Raw;
8285
export 'src/live.dart'
8386
show
8487
DecodedSample,

‎lib/src/commands.dart‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ Uint8List cmdGetHello(int seq, {BandProfile profile = BandProfile.gen4}) =>
101101
buildCommand(seq, Cmd.getHelloHarvard, const [0x00], profile);
102102
Uint8List cmdGetHelloModern(int seq, {BandProfile profile = BandProfile.gen4}) =>
103103
buildCommand(seq, Cmd.getHello, const [0x01], profile);
104+
105+
/// ABORT_HISTORICAL_TRANSMITS (0x14 = 20), no semantic body. [profile]
106+
/// selects the envelope (gen5 pads the bodyless inner to `[35][seq][20][00]`).
104107
Uint8List cmdAbortHistorical(int seq, {BandProfile profile = BandProfile.gen4}) =>
105108
buildCommand(seq, Cmd.abortHistoricalTransmits, const [0x00], profile);
106109

@@ -206,8 +209,8 @@ Uint8List cmdEnterHighFreqSync(int seq,
206209
// the permissive u16 range rather than inheriting a limit we cannot check.
207210
if (profile.isGen5) {
208211
if (intervalSeconds <= 60) {
209-
throw ArgumentError.value(intervalSeconds, 'intervalSeconds',
210-
'gen5 requires > 60 seconds');
212+
throw ArgumentError.value(
213+
intervalSeconds, 'intervalSeconds', 'gen5 requires > 60 seconds');
211214
}
212215
if (durationSeconds >= 28800) {
213216
throw ArgumentError.value(
@@ -417,9 +420,8 @@ Uint8List cmdSetAlarmSimple(int seq, DateTime when,
417420
/// whether this rev-1 body means anything to a gen5 is untested.
418421
Uint8List cmdSetAlarmRev1(int seq, DateTime when,
419422
{int hapticMode = 0, BandProfile profile = BandProfile.gen4}) =>
420-
buildCommand(
421-
seq, Cmd.setAlarmTime, alarmRev1Payload(when, hapticMode: hapticMode),
422-
profile);
423+
buildCommand(seq, Cmd.setAlarmTime,
424+
alarmRev1Payload(when, hapticMode: hapticMode), profile);
423425

424426
/// The bare 9-byte payload of the REV-1 alarm form (see [cmdSetAlarmRev1]).
425427
///
@@ -496,6 +498,7 @@ Uint8List cmdSetAlarm(
496498
DateTime when, {
497499
int? index,
498500
List<int>? hapticPattern,
501+
499502
/// gen5's trailing body byte — the **alarm type**, always `0` in practice.
500503
/// See the GENERATION DIFFERENCE note above for why this parameter keeps
501504
/// its third-party name.
@@ -1042,8 +1045,8 @@ enum LabradorOperation {
10421045
/// not something a retry fixes.
10431046
Uint8List cmdLabradorDataGeneration(int seq, LabradorOperation op,
10441047
{BandProfile profile = BandProfile.gen4}) =>
1045-
buildCommand(seq, Cmd.toggleLabradorDataGeneration,
1046-
[revision1, op.value], profile);
1048+
buildCommand(
1049+
seq, Cmd.toggleLabradorDataGeneration, [revision1, op.value], profile);
10471050

10481051
/// Enable/disable the filtered-reading RAW save (TOGGLE_LABRADOR_RAW_SAVE,
10491052
/// 125) — `[0x01][0|1]`. Part of the prepare step (ON) and of the stop step

‎lib/src/control.dart‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,7 @@ class R10Lite {
159159
/// them from. Reports what was ACCEPTED, never what the byte declared.
160160
final List<int> rrIntervalsMs;
161161

162-
R10Lite(this.tsEpoch, this.hr, this.counter,
163-
{this.rrIntervalsMs = const []});
162+
R10Lite(this.tsEpoch, this.hr, this.counter, {this.rrIntervalsMs = const []});
164163
}
165164

166165
R10Lite? parseR10Lite(Uint8List inner) {
@@ -384,6 +383,16 @@ class Gen5HelloInfo {
384383
/// `48 <= value < 86`.
385384
bool get isWhoop5 => opticalDiscriminator >= 48 && opticalDiscriminator < 86;
386385

386+
/// WHOOP MG — app generation `MAVERICK` in the official revision-1 HELLO
387+
/// parser: optical discriminator in `[0, 38)`. The physical MG reports 0.
388+
///
389+
/// Gated on [helloRevision] == 1, unlike [isWhoop5]: MG is the gate for the
390+
/// Labrador/ECG lifecycle, and a HELLO of an unknown revision must never be
391+
/// read through revision-1 offsets to grant that. The service UUID, the
392+
/// advertised name and command acceptance are NOT MG evidence — the
393+
/// ordinary WHOOP 5.0 shares all three and maps to `GOOSE` (optical 82).
394+
bool get isMaverick => helloRevision == 1 && opticalDiscriminator < 38;
395+
387396
/// [tsSeconds] gated to the plausible unix range, null otherwise.
388397
///
389398
/// The band ships with its RTC unset, and an unset RTC reports a near-1970
@@ -618,8 +627,7 @@ EventInfo? parseEvent(
618627
}) {
619628
if (inner.length < 4 || inner[0] != PacketType.event) return null;
620629
final eid = u16(inner, 2);
621-
final gen5ScopedOut =
622-
_kGen5ScopedEventIds.contains(eid) && !profile.isGen5;
630+
final gen5ScopedOut = _kGen5ScopedEventIds.contains(eid) && !profile.isGen5;
623631
final name = gen5ScopedOut ? 'EVENT_$eid' : EventId.name(eid);
624632
// Timestamp: whole seconds u32 @ [4], sub-seconds u16 @ [8]; the event body
625633
// begins at [12]. All guarded by length so short frames degrade cleanly.
@@ -988,7 +996,9 @@ CmdResponse? parseCommandResponse(Uint8List inner,
988996
if (statusOk && revOk && payload.length >= 63) {
989997
final oldest = u32(payload, 35);
990998
final newest = u32(payload, 59);
991-
if (_plausibleUnix(oldest) && _plausibleUnix(newest) && oldest <= newest) {
999+
if (_plausibleUnix(oldest) &&
1000+
_plausibleUnix(newest) &&
1001+
oldest <= newest) {
9921002
dec['range_oldest'] = oldest;
9931003
dec['range_newest'] = newest;
9941004
}
@@ -1470,7 +1480,8 @@ Decoded _decodeDataRecord(Uint8List inner,
14701480
// u32 timestamp, so it invents a plausible-looking bpm out of a clock.
14711481
// edge routes historical frames elsewhere and never reaches this, but the
14721482
// decoder must not fabricate for whoever does.
1473-
return Decoded('data_record', {'rec_type': inner.length > 1 ? inner[1] : -1});
1483+
return Decoded(
1484+
'data_record', {'rec_type': inner.length > 1 ? inner[1] : -1});
14741485
}
14751486
final recType = inner.length > 1 ? inner[1] : -1;
14761487
// Live R10 (HR + IMU) — surface HR for the live display. Checked before the

‎lib/src/labrador.dart‎

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
// labrador.dart — WHOOP MG Labrador records: the filtered ECG (revision 17)
2+
// and the raw ECG (revision 16).
3+
//
4+
// Evidence: official Android 5.458.0 Labrador parser + exact 50.41.1.0
5+
// firmware constructor, physically closed on a WHOOP MG
6+
// (reversing-whoop docs/mg/02, docs/mg/05). Every field below is the
7+
// source-proven use; bytes past the sample block are preserved but NOT named.
8+
//
9+
// Deliberately NOT part of the gen5 historical decoder family
10+
// (gen5_records.dart): R17 arrives LIVE as packet type 43 (REALTIME_RAW_DATA)
11+
// on the official foreground path, which that type-47-only dispatch never
12+
// sees; and the family's base `flags` (inner[2]) / `ppgSampleRateHz` would
13+
// name a byte this record gives no meaning to. R17 has its own flags byte at
14+
// inner[14].
15+
//
16+
// PURE Dart — dart:typed_data only.
17+
18+
import 'dart:typed_data';
19+
20+
import 'constants.dart';
21+
import 'framing.dart';
22+
23+
ByteData _view(Uint8List b) =>
24+
b.buffer.asByteData(b.offsetInBytes, b.lengthInBytes);
25+
26+
/// R17 inner[14] — HeartKey S2 state/transition and presence bits.
27+
class LabradorFlags {
28+
final int raw;
29+
const LabradorFlags(this.raw);
30+
31+
/// bit 0 — entering S2 state 1.
32+
bool get enteringS2One => (raw & 0x01) != 0;
33+
34+
/// bit 1 — current S2 state is 1. The official reducer appends ordinary
35+
/// active frames only while this is set; a valid active frame with it clear
36+
/// is the distinct explicit-RESTART branch.
37+
bool get currentS2One => (raw & 0x02) != 0;
38+
39+
/// bit 2 — S2 transition 1 -> 2 (physically `0x0c` on the terminal frame).
40+
bool get s2Transition1to2 => (raw & 0x04) != 0;
41+
42+
/// bit 3 — HeartKey presence (electrode contact, debounced by the band).
43+
bool get presence => (raw & 0x08) != 0;
44+
45+
@override
46+
String toString() => 'LabradorFlags(0x${raw.toRadixString(16)})';
47+
}
48+
49+
/// R17 inner[18] — HeartKey unreadable-reason mask.
50+
class LabradorUnreadableMask {
51+
final int raw;
52+
const LabradorUnreadableMask(this.raw);
53+
54+
bool get lowAmplitude => (raw & 0x01) != 0;
55+
bool get significantNoise => (raw & 0x02) != 0;
56+
bool get unstableSignal => (raw & 0x04) != 0;
57+
bool get notEnoughData => (raw & 0x08) != 0;
58+
59+
/// The set bits by name, in bit order. Bits above 3 are reported as
60+
/// `unknown_bits_0x..` rather than given a meaning.
61+
List<String> get reasons => [
62+
if (lowAmplitude) 'low_amplitude',
63+
if (significantNoise) 'significant_noise',
64+
if (unstableSignal) 'unstable_signal',
65+
if (notEnoughData) 'not_enough_data',
66+
if ((raw & ~0x0F) != 0)
67+
'unknown_bits_0x${(raw & ~0x0F).toRadixString(16).padLeft(2, '0')}',
68+
];
69+
70+
@override
71+
String toString() => 'LabradorUnreadableMask(0x${raw.toRadixString(16)})';
72+
}
73+
74+
/// One Labrador revision-17 packet: the band's live filtered-ECG cycle.
75+
///
76+
/// [samples] are 100 Hz filtered/decimated input-referred INTEGER MICROVOLTS
77+
/// exactly as transmitted: signed i16, no rescaling and no wrist-dependent
78+
/// sign flip (the wrist selector acts inside the AFE). No anatomical lead or
79+
/// polarity is claimed. [variabilityRaw] has no proven unit and is not a
80+
/// category input.
81+
class LabradorR17 {
82+
static const int revision = 17;
83+
84+
/// Fixed fields occupy inner[0..25]; samples start at 26.
85+
static const int fixedLen = 26;
86+
87+
/// Physical wire capacity: 100 i16 samples per packet.
88+
static const int maxSamples = 100;
89+
90+
/// `0xffff` at inner[21..22] means the variability value is unavailable.
91+
static const int variabilityUnavailable = 0xffff;
92+
93+
/// inner[0]: 43 (REALTIME_RAW_DATA, the official live path) or 47
94+
/// (HISTORICAL_DATA — a stored R17, which the official foreground flow never
95+
/// enables; see [parse]'s `allowStored`).
96+
final int packetType;
97+
98+
/// inner[2] — a generic packet-context marker the official R17 consumer
99+
/// ignores (`0x80` on the first all-zero boundary packet). Kept raw.
100+
final int headerSecondary;
101+
102+
final int sequence; // inner[3..6] u32 LE data-cycle sequence
103+
final int strapSeconds; // inner[7..10] u32 LE
104+
final int subseconds; // inner[11..12] u16 LE, 1/32768 s
105+
final int quality; // inner[13]
106+
final LabradorFlags flags; // inner[14]
107+
final int result; // inner[15] HeartKey result code (app category input)
108+
final int s2State; // inner[16]; 2 is terminal
109+
final int progress; // inner[17]; 100 terminal, 255 invalid/abort
110+
final LabradorUnreadableMask unreadable; // inner[18]
111+
final int averageHr; // inner[19] final/stored HR — persisted category input
112+
final int liveHr; // inner[20] current HR — live category input
113+
114+
/// inner[21..22] u16 LE, or null when the wire value is [variabilityUnavailable].
115+
/// Twice the RMS successive difference over 30 callback values (firmware);
116+
/// the callback unit is unresolved, so no physiological unit is exposed.
117+
final int? variabilityRaw;
118+
final int reserved; // inner[23]
119+
final int sampleCount; // inner[24..25] u16 LE, <= [maxSamples]
120+
final Int16List samples; // inner[26..26+2n)
121+
122+
/// Aligned bytes after the sample block, byte-exact, meaning unassigned.
123+
final Uint8List tail;
124+
125+
/// The exact inner packet these fields were read from.
126+
final Uint8List inner;
127+
128+
const LabradorR17({
129+
required this.packetType,
130+
required this.headerSecondary,
131+
required this.sequence,
132+
required this.strapSeconds,
133+
required this.subseconds,
134+
required this.quality,
135+
required this.flags,
136+
required this.result,
137+
required this.s2State,
138+
required this.progress,
139+
required this.unreadable,
140+
required this.averageHr,
141+
required this.liveHr,
142+
required this.variabilityRaw,
143+
required this.reserved,
144+
required this.sampleCount,
145+
required this.samples,
146+
required this.tail,
147+
required this.inner,
148+
});
149+
150+
bool get presence => flags.presence;
151+
152+
/// The official app's completion condition.
153+
bool get isTerminal => progress == 100 || s2State == 2;
154+
155+
/// The official app's invalid/abort sentinel.
156+
bool get isInvalid => progress == 255;
157+
158+
bool get isLive => packetType == PacketType.realtimeRawData;
159+
160+
/// Strap acquisition-cycle time in seconds.
161+
double get strapTime => strapSeconds + subseconds / 32768.0;
162+
163+
/// Parse an inner packet. Returns null unless it is a type-43 (or, with
164+
/// [allowStored], type-47) packet of data revision 17 whose declared sample
165+
/// block fits: fixed fields through offset 25 present, count <= 100 and
166+
/// `26 + 2 * count` bytes available. No fixed total length is required;
167+
/// bytes beyond the sample block land in [tail].
168+
///
169+
/// CRC validity is the caller's business (see [tryParseFrame]).
170+
static LabradorR17? parse(Uint8List inner, {bool allowStored = false}) {
171+
if (inner.length < fixedLen) return null;
172+
final pt = inner[0];
173+
if (pt != PacketType.realtimeRawData &&
174+
!(allowStored && pt == PacketType.historicalData)) {
175+
return null;
176+
}
177+
if (inner[1] != revision) return null;
178+
final v = _view(inner);
179+
final count = v.getUint16(24, Endian.little);
180+
if (count > maxSamples) return null;
181+
final end = fixedLen + 2 * count;
182+
if (inner.length < end) return null;
183+
final samples = Int16List(count);
184+
for (var i = 0; i < count; i++) {
185+
samples[i] = v.getInt16(fixedLen + 2 * i, Endian.little);
186+
}
187+
final variability = v.getUint16(21, Endian.little);
188+
return LabradorR17(
189+
packetType: pt,
190+
headerSecondary: inner[2],
191+
sequence: v.getUint32(3, Endian.little),
192+
strapSeconds: v.getUint32(7, Endian.little),
193+
subseconds: v.getUint16(11, Endian.little),
194+
quality: inner[13],
195+
flags: LabradorFlags(inner[14]),
196+
result: inner[15],
197+
s2State: inner[16],
198+
progress: inner[17],
199+
unreadable: LabradorUnreadableMask(inner[18]),
200+
averageHr: inner[19],
201+
liveHr: inner[20],
202+
variabilityRaw:
203+
variability == variabilityUnavailable ? null : variability,
204+
reserved: inner[23],
205+
sampleCount: count,
206+
samples: samples,
207+
tail: Uint8List.fromList(inner.sublist(end)),
208+
inner: Uint8List.fromList(inner),
209+
);
210+
}
211+
212+
/// [parse] over a reassembled frame, accepting only a frame whose header
213+
/// CRC, payload CRC and frame revision all check out ([Frame.decodable]).
214+
static LabradorR17? tryParseFrame(Frame f, {bool allowStored = false}) {
215+
if (!f.decodable) return null;
216+
return parse(f.inner, allowStored: allowStored);
217+
}
218+
}
219+
220+
/// A historical (type 47) revision-16 raw ECG record, recognised but NOT
221+
/// decoded: only the proven common header (sequence and strap time, the same
222+
/// offsets every gen5 data record shares) is read, and the exact inner bytes
223+
/// are kept for durable storage. The body layout is not source-closed, so no
224+
/// body field is invented here.
225+
class LabradorR16Raw {
226+
static const int revision = 16;
227+
228+
/// Physically observed sizes (1,572-byte inner, 1,584-byte frame) —
229+
/// documentation, not enforced: a record only needs its common header.
230+
static const int observedInnerLen = 1572;
231+
static const int observedFrameLen = 1584;
232+
233+
/// The common data-record header ends after the u16 sub-second at 11..12.
234+
static const int headerLen = 13;
235+
236+
final int sequence; // inner[3..6] u32 LE
237+
final int strapSeconds; // inner[7..10] u32 LE
238+
final int subseconds; // inner[11..12] u16 LE, 1/32768 s
239+
final Uint8List inner; // exact bytes
240+
241+
const LabradorR16Raw({
242+
required this.sequence,
243+
required this.strapSeconds,
244+
required this.subseconds,
245+
required this.inner,
246+
});
247+
248+
double get strapTime => strapSeconds + subseconds / 32768.0;
249+
250+
/// Recognise a type-47 revision-16 inner packet; null for anything else.
251+
static LabradorR16Raw? tryParse(Uint8List inner) {
252+
if (inner.length < headerLen) return null;
253+
if (inner[0] != PacketType.historicalData) return null;
254+
if (inner[1] != revision) return null;
255+
final v = _view(inner);
256+
return LabradorR16Raw(
257+
sequence: v.getUint32(3, Endian.little),
258+
strapSeconds: v.getUint32(7, Endian.little),
259+
subseconds: v.getUint16(11, Endian.little),
260+
inner: Uint8List.fromList(inner),
261+
);
262+
}
263+
264+
/// [tryParse] over a reassembled frame that passed both CRCs and the
265+
/// revision check.
266+
static LabradorR16Raw? tryParseFrame(Frame f) =>
267+
f.decodable ? tryParse(f.inner) : null;
268+
}

0 commit comments

Comments
 (0)