|
| 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