diff --git a/lib/src/commands.dart b/lib/src/commands.dart index 9cc2b82..0f16c10 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -358,8 +358,11 @@ Uint8List cmdSetAlarmSimple(int seq, DateTime when, /// STRAP_DRIVEN_ALARM_SET (56) event and its firing via /// STRAP_DRIVEN_ALARM_EXECUTED (57) / HAPTICS_FIRED (60). /// -/// [index] is the alarm slot, 1..6 — NOT 0, which RUN_ALARM/DISABLE_ALARM -/// reject, leaving the alarm un-runnable and un-cancellable. +/// [index] is the alarm slot, and the usable range is per-generation (see +/// [_checkAlarmId]): gen4 is 0..6 and DEFAULTS to 0, the slot a real WHOOP 4 +/// was verified to fire from; gen5 is 1..6, because there RUN_ALARM and +/// DISABLE_ALARM reject 0 and an alarm in slot 0 is un-runnable and +/// un-cancellable. Omit [index] to get the right default for the profile. /// /// GENERATION DIFFERENCE — the haptic block length: /// • gen4 reads 12 bytes (payload 20). Hardware-verified; unchanged. @@ -578,9 +581,12 @@ Uint8List cmdBuzzGen5Maverick(int seq, {int overallLoop = 1}) { // Opcode-identical to gen4's SET_FF_VALUE, but gen5's R22 deep buffers // (v20 optical / v21 IMU / v26 PPG — see gen5_records.dart) are OFF by // default even in the official WHOOP app; a strap will only ever emit v18 -// unless this 16-flag sequence is sent first. Byte-verified body shape (a -// real `enable_r22_packets` capture): 40-byte body = ASCII key name -// NUL-padded to 32 bytes, + 1 value byte @ offset 32, + 7 zero bytes. +// unless this 16-flag sequence is sent first. Body shape: 65 bytes = +// `[0x01 revision][name:32B NUL-padded ASCII][value:32B NUL-padded ASCII]`. +// (An older note here described a 40-byte, revision-less body with the name at +// offset 0 — that is the form the strap REJECTED, reading the name's first byte +// as the revision. Opcode 120 is a persistent NVM write, so build it with the +// builders below, not from a remembered capture.) /// A 32-byte NUL-padded ASCII key/value field — the unit both SET_CONFIG (120) /// and SET_DEVICE_CONFIG_VALUE (119) are built from. [max] leaves room for the diff --git a/lib/src/constants.dart b/lib/src/constants.dart index df9e9e7..5f04007 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -35,8 +35,10 @@ class Cmd { // Erase the bond / pairing keys. DANGER — the link drops and the user must // re-pair from scratch; there is no undo. static const int forgetBonds = 0x0F; // DANGER - static const int setClock = - 0x0A; // [u32 epoch LE, u32 pad] — set the strap RTC + // Set the strap RTC. Body is TWO u32 LE: [0:4] whole epoch seconds, [4:8] + // sub-seconds in 1/32768 s — NOT padding. Wrong length/shape is ACK'd but not + // latched (see cmdSetClock), so build it there. + static const int setClock = 0x0A; static const int getClock = 0x0B; // → strap RTC epoch (ClockRef correlation) static const int abortHistoricalTransmits = 0x14; static const int sendHistoricalData = 0x16; @@ -175,9 +177,13 @@ class Cmd { static const int runHapticPatternMaverick = 0x13; // 19 // SET_DEVICE_CONFIG_VALUE — smaller/older sibling of SET_FF_VALUE (120). static const int setDeviceConfigValue = 119; - // SET_FF_VALUE / SET_CONFIG — 40-byte name+value body. Shared opcode - // number across generations; this is how the gen5 R22 deep-buffer enable - // sequence is sent (see commands.dart's buildR22EnableSequence). NOTE: this + // SET_FF_VALUE / SET_CONFIG. Shared opcode number across generations; the + // only body shape this package builds is gen5's 65 bytes — + // `[0x01 revision][name:32B NUL-padded ASCII][value:32B NUL-padded ASCII]`, + // see cmdSetConfigGen5. (An earlier note here said 40 bytes: that is the + // revision-less form the strap REJECTED.) This is how the gen5 R22 + // deep-buffer enable sequence is sent (see commands.dart's + // buildR22EnableSequence). NOTE: this // opcode is ALSO in [OpcodeSafety.forbidden] — see that class's doc for why // that is not a contradiction. static const int setFfValue = 120; diff --git a/lib/src/control.dart b/lib/src/control.dart index 5a5162f..adf556f 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -156,12 +156,39 @@ class R10Lite { final int tsEpoch; // u32 @[7:11] final int hr; // u8 @[17] final int counter; // u32 @[3:7] - R10Lite(this.tsEpoch, this.hr, this.counter); + + /// Beat-to-beat intervals (ms): declared count @[18], `i16` LE from [19]. + /// + /// Header-ONLY used to mean the R-R block was dropped too. On the historical + /// ingest path that is permanent: the record commits as decoded (so it is not + /// archived) and the band is then acked to trim it, so beats that were + /// physically present in the offloaded bytes had nothing left to recover + /// them from. Reports what was ACCEPTED, never what the byte declared. + final List rrIntervalsMs; + + R10Lite(this.tsEpoch, this.hr, this.counter, + {this.rrIntervalsMs = const []}); } R10Lite? parseR10Lite(Uint8List inner) { if (inner.length < 18) return null; - return R10Lite(u32(inner, 7), inner[17], u32(inner, 3)); + return R10Lite(u32(inner, 7), inner[17], u32(inner, 3), + rrIntervalsMs: _r10Rr(inner)); +} + +/// Same offsets and the same accept-only-plausible-values discipline the live +/// decoder applies (`live.dart`'s `realtimeRr`, R10 branch). +List _r10Rr(Uint8List inner) { + if (inner.length < 19) return const []; + final declared = inner[18]; + if (declared == 0 || declared > kMaxRrPerRecord) return const []; + final view = ByteData.sublistView(inner); + final out = []; + for (var i = 0; i < declared && 19 + 2 * i + 2 <= inner.length; i++) { + final v = view.getInt16(19 + 2 * i, Endian.little); + if (v >= kMinRrMs && v <= kMaxRrMs) out.add(v); + } + return out; } // ── Compact realtime HR (small 0x28 packet body) ───────────────────────────── @@ -234,9 +261,15 @@ RealtimeHrV2? parseRealtimeHrV2(Uint8List body) { // ── HELLO identity ─────────────────────────────────────────────────────────── class HelloInfo { double? batteryPct; + + /// Always null out of [parseHello] — no charging flag has been located in a + /// real HELLO body. Take charging from the CHARGING_ON/OFF events. bool? charging; String? serial; String? commit; + + /// Always null out of [parseHello] — same story as [charging]. Wear comes + /// from the WRIST_ON/OFF events (and realtime HR's `wearing` bit). bool? wristOn; String rawHex; HelloInfo({ @@ -298,8 +331,13 @@ HelloInfo parseHello(Uint8List payload) { } } } - if (payload.length > 5) info.charging = payload[5] != 0; - if (payload.length > 116) info.wristOn = payload[116] != 0; + // NO charging / wristOn here. Both used to be read at asserted offsets that + // the real bodies disprove: payload[5] is the zero high byte of the u32 the + // battery scan above resolves at [3], so `charging` was structurally always + // false; payload[116] sits in the all-zero tail of all three captured HELLO + // bodies, so `wristOn` was always false and overwrote a true learned from the + // WRIST_ON event. Both stay null — absent, not a fabricated "no". Charging + // comes from CHARGING_ON/OFF and wear from WRIST_ON/OFF. // Serial is a NUL-terminated ASCII token at a FIXED offset in the body: // payload[16] (= inner[19]), immediately followed by the 64-char firmware @@ -879,20 +917,26 @@ ConsoleLogChunk? parseConsoleLog(Uint8List inner) { /// text together. class ConsoleLogReassembler { final StringBuffer _buf = StringBuffer(); + + /// A run that a gap ended, waiting to be handed back by the next [flush]. + /// Only the most recent one is held: a caller that ignores a `false` from + /// [add] and lets a second gap arrive loses the earlier run. + String? _completed; int? _lastIndex; /// Feed one decoded chunk. Returns true if it extended the current - /// contiguous run; false if it started a new one (the old run, if any, was - /// flushed into the return value of the PREVIOUS [flush] call — callers - /// should call [flush] after a `false` return to retrieve what came before). + /// contiguous run; false if it started a new one — call [flush] after a + /// `false` return to get the run the gap ended. bool add(ConsoleLogChunk chunk) { final contiguous = _lastIndex != null && // record_index is a u8 — allow wraparound at 0xFF, matching the // wire's own modulo-256 counter. (chunk.recordIndex == (_lastIndex! + 1) & 0xFF); if (_lastIndex != null && !contiguous) { - // Caller must flush() before this chunk's text is appended, or the - // discontinuity is silently lost. We still start the new run here. + // The gap ENDS the buffered run — park it for the caller's next flush(). + // This used to just _buf.clear(), which destroyed the run before the + // caller could ask for it, so a dropped frame ate the line before it. + if (_buf.isNotEmpty) _completed = _buf.toString(); _buf.clear(); } _buf.write(chunk.text); @@ -900,8 +944,15 @@ class ConsoleLogReassembler { return contiguous; } - /// Return everything accumulated so far and reset for the next run. + /// Return the oldest run we are holding. If a gap ended a run, that run comes + /// back first and the run being built keeps buffering; otherwise this returns + /// what has accumulated and resets for the next run. String flush() { + final done = _completed; + if (done != null) { + _completed = null; + return done; + } final s = _buf.toString(); _buf.clear(); _lastIndex = null; @@ -1019,8 +1070,16 @@ Decoded _decodeDataRecord(Uint8List inner, if (recType == Record.r10) { final r = parseR10Lite(inner); if (r != null && r.hr > 0) { - return Decoded( - 'realtime_hr', {'rec_type': recType, 'hr': r.hr, 'wearing': true}); + // rr_ms too: parseR10Lite already accepted these beats, and the short + // realtime-HR branch above emits them — dropping them here silently + // halved the beat supply of anything reading live R10 through + // decodeFrame rather than live.dart. + return Decoded('realtime_hr', { + 'rec_type': recType, + 'hr': r.hr, + 'rr_ms': r.rrIntervalsMs, + 'wearing': true, + }); } } // R24: delegate to the native Dart full-record decoder (Source 1). diff --git a/lib/src/framing.dart b/lib/src/framing.dart index 313fd14..e4cc58b 100644 --- a/lib/src/framing.dart +++ b/lib/src/framing.dart @@ -46,6 +46,11 @@ class Frame { /// Safe to read [packetType]/[seq]/[opcode] with the rev-1 field offsets: /// CRCs pass AND the frame revision is one this decoder understands. Check /// this (not just [valid]) before trusting [opcode] on an inbound frame. + /// + /// `valid && !decodable` means the bytes are INTACT and we just cannot read + /// this revision's field map — archive them, never drop them. Dropping is how + /// a firmware revision bump turns into a silent zero-record sync while the + /// band goes on trimming records we could have re-decoded later. bool get decodable => valid && frameRevOk; int get packetType => inner.isNotEmpty ? inner[0] : -1; int get seq => inner.length > 1 ? inner[1] : -1; diff --git a/lib/src/gen5_records.dart b/lib/src/gen5_records.dart index ca6c80e..1d6b0a6 100644 --- a/lib/src/gen5_records.dart +++ b/lib/src/gen5_records.dart @@ -186,7 +186,10 @@ enum Gen5SleepState { /// resolved from bytes alone; those are called out explicitly rather than /// silently picking a side. class Gen5HistorySample extends Gen5HistoricalRecord { - /// bpm. 0 is a legitimate reading (device warming up), not absence. + /// bpm. 0 is the band's own "no reading this second" (warming up / off skin), + /// and it is also what we emit when the HR byte lands outside 25..230 — an + /// out-of-range byte is not a heart rate, and the rest of the record still + /// decodes. Never a clamped or carried-forward number. final int heartRate; /// Number of R-R intervals we ACCEPTED (see [rrIntervalsMs] doc) — always @@ -234,14 +237,16 @@ class Gen5HistorySample extends Gen5HistoricalRecord { final int cardiacStatusRaw; /// Gravity-removed motion magnitude (g) @ inner[33:37] f32 LE (frame-abs - /// 41). Gated finite ∈ [0, 8] at decode time (see [Gen5V18Decoder]). - final double dynamicAccelerationG; - - /// [x, y, z] (g) @ inner[37/41/45] f32 LE each (frame-abs 45/49/53). Gated - /// finite with magnitude ∈ [0.5, 1.8] g at decode time — the same window - /// gen4 uses. These are per-axis means of raw accel, not a normalised - /// vector, so a worn strap in motion legitimately reads above 1 g; do not - /// re-reject on a tighter bound than the decoder already applies. + /// 41). NULL when the bytes are not a finite in-full-scale value — absent, + /// not zero. The rest of the record is still valid (see [Gen5V18Decoder]). + final double? dynamicAccelerationG; + + /// [x, y, z] (g) @ inner[37/41/45] f32 LE each (frame-abs 45/49/53), or + /// EMPTY when absent. These are per-axis means of raw accel, NOT a + /// normalised gravity vector, so a worn strap in motion legitimately reads + /// well above 1 g: do not gate this field on gen4's gravity-magnitude + /// window, or on any bound derived from the other generation. The decoder + /// checks only finiteness and the part's ±16 g full scale. final List gravityG; /// Cumulative on-chip step counter @ inner[49:51] u16 LE (frame-abs 57). @@ -347,7 +352,10 @@ class Gen5HistorySample extends Gen5HistoricalRecord { /// Usable as a quality weight (e.g. to down-weight a second's HR/RR), which /// is what it is for. The absolute scale is the band's, not a physical unit, /// so compare it against other seconds, not against a threshold you invent. - final double signalQualityLogVariance; + /// + /// Null when those bytes are not finite — they are then not this f32, and a + /// NaN weight silently poisons every comparison and mean it touches. + final double? signalQualityLogVariance; const Gen5HistorySample({ required super.histVersion, @@ -469,8 +477,14 @@ class Gen5V18Decoder implements Gen5RecordDecoder { if (hdr == null) return null; final v = _view(inner); - final hr = inner[14]; - if (hr != 0 && (hr < 25 || hr > 230)) return null; // implausible + // An implausible HR byte costs ONLY THE HR, same as the accel below. It + // used to `return null`, which archived the whole record undecoded — so one + // artefact bpm also threw away that second's R-R beats, skin temp, steps + // and sleep state, and the band then trimmed them. 0 is the stack's + // hr-absent sentinel (`hr == 0` is the off-skin/no-reading case every + // consumer already gates on), so the rest of the second survives. + final hrRaw = inner[14]; + final hr = (hrRaw >= 25 && hrRaw <= 230) ? hrRaw : 0; // R-R: up to 4 slots @ inner[16/18/20/22], declared count @ inner[15]. // Same accept-only-plausible-values discipline as records.dart's R24. @@ -483,34 +497,31 @@ class Gen5V18Decoder implements Gen5RecordDecoder { } } - // A bad accel costs the WHOLE record, deliberately, and this is the lesser - // of two bad options until the storage can say "absent". - // - // Keeping the record and reporting only the accel as absent is the right - // shape and was tried. It does not survive persistence: decoded_onehz's - // ax/ay/az are REAL NOT NULL, so an absent vector is written as exact - // (0, 0, 0), and zAngle(0,0,0) is 0.0 rather than NaN — a run of those - // reads as a perfectly still wrist. Only the van Hees coverage gate - // consults the absent-marker; immobility, auto-workout detection and the - // ENMO feeds all take the axes raw, so the substituted zeros would become - // confident stillness in four places instead of an honest gap in one. + // An unusable accel costs ONLY THE ACCEL. It used to cost the whole record + // (`return null` ⇒ archived undecoded) because the store could not say + // "absent"; it can now, so HR/RR/steps/temp from the same second survive. // - // Returning null archives the record with its bytes intact, so nothing is - // lost — it can be re-decoded once the columns are nullable and the readers - // are absence-aware. Widening this window (it was 2.25) already cut how - // often it fires. + // NO GEN4 BOUND ON THIS FIELD. The window that used to sit here + // (magSq ∈ [0.25, 3.24], i.e. 0.5–1.8 g) is gen4's, and it is a bound on a + // NORMALISED GRAVITY VECTOR. Gen5's is a different physical quantity — the + // per-axis means of raw accel — so a wrist in hard motion legitimately + // exceeds it, and applying it here rejected exactly the workout seconds + // that matter. All that is left is a full-scale sanity check: the axes come + // off a ±16 g part, so anything beyond that is a mis-framed decode, not a + // reading. Gen5 is HARDWARE-UNTESTED; this is UNVERIFIED either way, and + // absence is the loud-not-silent answer. + const fullScaleG = 16.0; final dynAccel = _round(v.getFloat32(33, Endian.little), 4); - if (!dynAccel.isFinite || dynAccel < 0 || dynAccel > 8) return null; - final gx = _round(v.getFloat32(37, Endian.little), 4); final gy = _round(v.getFloat32(41, Endian.little), 4); final gz = _round(v.getFloat32(45, Endian.little), 4); - if (!gx.isFinite || !gy.isFinite || !gz.isFinite) return null; - // These are per-axis means of raw accel, NOT a normalised gravity vector, - // so a worn strap in motion legitimately exceeds 1.5 g. Same window gen4 - // uses (records.dart); reject only physically impossible vectors. - final magSq = gx * gx + gy * gy + gz * gz; - if (magSq < 0.25 || magSq > 3.24) return null; // 0.5g..1.8g + final gravityOk = gx.isFinite && + gy.isFinite && + gz.isFinite && + gx.abs() <= fullScaleG && + gy.abs() <= fullScaleG && + gz.abs() <= fullScaleG; + final dynOk = dynAccel.isFinite && dynAccel >= 0 && dynAccel <= fullScaleG; return Gen5HistorySample( histVersion: hdr.version, @@ -526,8 +537,8 @@ class Gen5V18Decoder implements Gen5RecordDecoder { heartRateAlt: inner[29], rrPacked: v.getUint16(30, Endian.little), cardiacStatusRaw: inner[32], - dynamicAccelerationG: dynAccel, - gravityG: [gx, gy, gz], + dynamicAccelerationG: dynOk ? dynAccel : null, + gravityG: gravityOk ? [gx, gy, gz] : const [], stepMotionCounter: v.getUint16(49, Endian.little), stepCadence: inner[51], activityClass: inner[55], @@ -541,7 +552,8 @@ class Gen5V18Decoder implements Gen5RecordDecoder { spo2CandidateRaw: inner[74], opticalBaseline: v.getUint16(98, Endian.big), opticalAmp: v.getUint16(100, Endian.big), - signalQualityLogVariance: _round(v.getFloat32(105, Endian.little), 4), + signalQualityLogVariance: + _finiteOrNull(_round(v.getFloat32(105, Endian.little), 4)), ); } } diff --git a/lib/src/records.dart b/lib/src/records.dart index 5d83b76..603d360 100644 --- a/lib/src/records.dart +++ b/lib/src/records.dart @@ -340,7 +340,8 @@ bool _physiologicallyPlausible(List accelG, int hr) { /// - v18 / v9 / v7 → the SAME field map but with the HR byte read at that /// version's offset (only the HR offset is independently confirmed for /// these, so the decode is returned only if it passes a physiological -/// plausibility gate — otherwise null). +/// plausibility gate — otherwise null). Timing, HR and accel only: the +/// R-R block and the optical block are v24's map and come back absent. /// - any other version → treated as an unknown/future layout: we attempt the /// v24 field map and return it only if it is physiologically plausible. /// This degrades a firmware layout change to a validated best-effort read @@ -473,6 +474,15 @@ R24? _parseV24Layout( return null; } + // The optical block gets the SAME rule as rr_count above: it is v24's field + // map, so it is read only where that map is confirmed (v24/v12, i.e. + // !validate). For every other version the plausibility gate evidences HR, + // timing and accel and nothing else — bytes at 29/31/51/64/66/68/70 on a + // layout known to differ (v7 puts HR at 27, v18 at 14) are not the optical + // channels, and feeding them to relative-ODI hands the user a desaturation + // number computed from unknown bytes. 0 is this stack's ADC-absent sentinel + // (every consumer gates on `v > 0`), same as _parseV25 already emits. + final optical = !validate; return R24( histVersion: version, tsEpoch: view.getUint32(7, Endian.little), @@ -481,14 +491,14 @@ R24? _parseV24Layout( hr: hr, rrCount: rrCount, rrIntervalsMs: rrIntervalsMs, - ppgGreen: view.getUint16(29, Endian.little), - ppgRedIr: view.getUint16(31, Endian.little), + ppgGreen: optical ? view.getUint16(29, Endian.little) : 0, + ppgRedIr: optical ? view.getUint16(31, Endian.little) : 0, accelG: accelG, - skinContact: inner[51], - spo2RedRaw: view.getUint16(64, Endian.little), - spo2IrRaw: view.getUint16(66, Endian.little), - skinTempRaw: view.getUint16(68, Endian.little), - ambientRaw: view.getUint16(70, Endian.little), + skinContact: optical ? inner[51] : 0, + spo2RedRaw: optical ? view.getUint16(64, Endian.little) : 0, + spo2IrRaw: optical ? view.getUint16(66, Endian.little) : 0, + skinTempRaw: optical ? view.getUint16(68, Endian.little) : 0, + ambientRaw: optical ? view.getUint16(70, Endian.little) : 0, // COPY, not sublistView: a view aliases the caller's buffer, and rawTail // is now encoded lazily, so a later mutation of `inner` would change bytes // that were supposed to be a snapshot of parse time. diff --git a/test/control_plane_offsets_test.dart b/test/control_plane_offsets_test.dart index 8048a1d..fe78e0c 100644 --- a/test/control_plane_offsets_test.dart +++ b/test/control_plane_offsets_test.dart @@ -253,6 +253,18 @@ void main() { expect(r.add(parseConsoleLog(logPacket(0x00, 'c'))!), isTrue); expect(r.flush(), 'abc'); }); + + test('a gap hands back the run it ended instead of eating it', () { + // add() used to clear the buffer itself, so by the time the caller saw + // the false return the earlier line was already gone. + final r = ConsoleLogReassembler(); + r.add(parseConsoleLog(logPacket(5, 'BLE_CMD: Command Send '))!); + r.add(parseConsoleLog(logPacket(6, 'Historical Data\n'))!); + expect(r.add(parseConsoleLog(logPacket(9, 'after the gap'))!), isFalse); + expect(r.flush(), 'BLE_CMD: Command Send Historical Data\n'); + expect(r.add(parseConsoleLog(logPacket(10, '!'))!), isTrue); + expect(r.flush(), 'after the gap!'); + }); }); group('HELLO battery scan starts on the body, not the status byte', () { diff --git a/test/decode_integrity_test.dart b/test/decode_integrity_test.dart index 1734338..0247854 100644 --- a/test/decode_integrity_test.dart +++ b/test/decode_integrity_test.dart @@ -87,35 +87,128 @@ void main() { }); }); - group('a gen5 v18 with an unusable accel is archived, not fabricated', () { - test('the record is declined so nothing writes a zeroed gravity vector', () { - // Keeping the second and reporting only the accel as absent is the right - // shape, but it does not survive storage: decoded_onehz's ax/ay/az are - // REAL NOT NULL, so absent becomes exact (0,0,0), and zAngle(0,0,0) is - // 0.0 rather than NaN — a run of those reads as a perfectly still wrist - // in four consumers that never consult the absent-marker. Declining sends - // the record to raw_archive with its bytes intact instead, so it can be - // re-decoded once the columns are nullable. + group('the optical block is read only where the field map is confirmed', () { + // Same rule as the beats above: on a version whose layout is known to + // differ, bytes at 29/64/66/68/70 are not the optical channels, and + // relative-ODI would turn them into a desaturation number. + Uint8List withOptical(int version, int hrOffset) { + final inner = _record(version: version, hrOffset: hrOffset); + final v = ByteData.sublistView(inner); + v.setUint16(29, 1111, Endian.little); // ppg green + inner[51] = 66; // skin contact byte + v.setUint16(64, 2222, Endian.little); // spo2 red + v.setUint16(66, 3333, Endian.little); // spo2 ir + v.setUint16(68, 4444, Endian.little); // "skin temp" + v.setUint16(70, 5555, Endian.little); // ambient + return inner; + } + + test('v24 keeps its optical block', () { + final r = parseR24(withOptical(24, 17))!; + expect(r.ppgGreen, 1111); + expect(r.spo2RedRaw, 2222); + expect(r.spo2IrRaw, 3333); + expect(r.ambientRaw, 5555); + }); + + test('an unconfirmed version reports the optical block absent', () { + for (final entry in {7: 27, 18: 14, 9: 17}.entries) { + final r = parseR24(withOptical(entry.key, entry.value)); + expect(r, isNotNull, reason: 'v${entry.key} still decodes'); + expect(r!.ppgGreen, 0, reason: 'v${entry.key} ppg'); + expect(r.spo2RedRaw, 0, reason: 'v${entry.key} red'); + expect(r.spo2IrRaw, 0, reason: 'v${entry.key} ir'); + expect(r.ambientRaw, 0, reason: 'v${entry.key} ambient'); + expect(r.hr, 60, reason: 'v${entry.key} keeps HR and timing'); + expect(r.tsEpoch, 1780000000); + } + }); + }); + + group('a gen5 v18 with an unusable accel keeps the rest of the record', () { + test('an unusable accel is reported ABSENT, not fabricated and not fatal', + () { + // Declining the whole record used to be the lesser evil, because + // decoded_onehz's ax/ay/az were REAL NOT NULL and absent would have been + // stored as exact (0,0,0) — a perfectly still wrist. Storage can say + // "absent" now (edge schema v39), so an unreadable accel costs the accel + // and nothing else: HR, RR, steps and temperature from the same second + // survive. final inner = Uint8List(kGen5V18InnerLen); inner[0] = PacketType.historicalData; inner[1] = 18; final v = ByteData.sublistView(inner); v.setUint32(7, 1780000000, Endian.little); inner[14] = 102; + v.setFloat32(33, double.nan, Endian.little); + v.setFloat32(37, 4.0e6, Endian.little); // beyond any real full scale + v.setFloat32(41, 4.0e6, Endian.little); + v.setFloat32(45, 4.0e6, Endian.little); + final g = parseGen5Historical(inner) as Gen5HistorySample?; + expect(g, isNotNull); + expect(g!.heartRate, 102); + expect(g.gravityG, isEmpty, reason: 'absent, not (0,0,0)'); + expect(g.dynamicAccelerationG, isNull); + }); + + test('NO gen4 gravity window on gen5: hard motion still decodes', () { + // gen4's magSq window [0.25, 3.24] is a bound on a NORMALISED GRAVITY + // VECTOR. Gen5's gravityG is a different quantity — per-axis means of + // raw accel — so a wrist in hard motion legitimately reads well above + // 1.8 g, and the borrowed window rejected the whole record for exactly + // the workout seconds that matter. + final inner = Uint8List(kGen5V18InnerLen); + inner[0] = PacketType.historicalData; + inner[1] = 18; + final v = ByteData.sublistView(inner); + v.setUint32(7, 1780000000, Endian.little); + inner[14] = 165; // working hard + v.setFloat32(33, 3.5, Endian.little); + v.setFloat32(37, 2.5, Endian.little); // magSq = 18.75, far past 3.24 + v.setFloat32(41, 2.5, Endian.little); + v.setFloat32(45, 2.5, Endian.little); + final g = parseGen5Historical(inner) as Gen5HistorySample?; + expect(g, isNotNull); + expect(g!.heartRate, 165); + expect(g.gravityG, [2.5, 2.5, 2.5]); + expect(g.dynamicAccelerationG, 3.5); + }); + + test('an implausible HR costs the HR, not the record', () { + // 240 bpm is an optical artefact, not a broken frame. Rejecting the whole + // record threw away that second's beats, temp and steps as well — and the + // band then trimmed them. + final inner = Uint8List(kGen5V18InnerLen); + inner[0] = PacketType.historicalData; + inner[1] = 18; + final v = ByteData.sublistView(inner); + v.setUint32(7, 1780000000, Endian.little); + inner[14] = 240; // out of 25..230 + inner[15] = 2; // two beats + v.setInt16(16, 800, Endian.little); + v.setInt16(18, 810, Endian.little); v.setFloat32(33, 0.01, Endian.little); - v.setFloat32(37, 40.0, Endian.little); // gravity far out of window - v.setFloat32(41, 40.0, Endian.little); - v.setFloat32(45, 40.0, Endian.little); - expect(parseGen5Historical(inner), isNull); - - // A non-finite dynamic-accel word is declined for the same reason. - final nan = Uint8List.fromList(inner); - final nv = ByteData.sublistView(nan); - nv.setFloat32(33, double.nan, Endian.little); - nv.setFloat32(37, 0.0, Endian.little); - nv.setFloat32(41, 0.0, Endian.little); - nv.setFloat32(45, 1.0, Endian.little); - expect(parseGen5Historical(nan), isNull); + v.setFloat32(37, 0.0, Endian.little); + v.setFloat32(41, 0.0, Endian.little); + v.setFloat32(45, 1.0, Endian.little); + v.setInt16(65, 3057, Endian.little); + final g = parseGen5Historical(inner) as Gen5HistorySample?; + expect(g, isNotNull); + expect(g!.heartRate, 0, reason: 'absent, never the artefact bpm'); + expect(g.rrIntervalsMs, [800, 810]); + expect(g.skinTempC, closeTo(30.57, 0.01)); + }); + + test('a signal-quality float that is not finite comes back null', () { + final inner = Uint8List(kGen5V18InnerLen); + inner[0] = PacketType.historicalData; + inner[1] = 18; + final v = ByteData.sublistView(inner); + v.setUint32(7, 1780000000, Endian.little); + inner[14] = 60; + v.setFloat32(105, double.nan, Endian.little); + final g = parseGen5Historical(inner) as Gen5HistorySample?; + expect(g?.signalQualityLogVariance, isNull); }); test('a good accel still decodes the whole record', () { diff --git a/test/hello_test.dart b/test/hello_test.dart index 0593619..078690b 100644 --- a/test/hello_test.dart +++ b/test/hello_test.dart @@ -33,6 +33,18 @@ void main() { } }); + test('charging and wristOn are absent, not a fabricated false', () { + // payload[5] is the zero high byte of the battery u32 and payload[116] sits + // in the all-zero tail, so both used to report a confident "no" on every + // real capture — wristOn then overwrote a true from the WRIST_ON event. + for (final body in helloBodies) { + final h = parseHello(hexToBytes(body)); + expect(h.charging, isNull); + expect(h.wristOn, isNull); + expect(h.batteryPct, isNotNull, reason: 'battery still decodes'); + } + }); + test('commit hex follows the serial', () { final h = parseHello(hexToBytes(helloBodies.first)); expect(h.commit, isNotNull);