From 539a97b1fb5036456c4340e8f4fc24558514c490 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:15:05 +0530 Subject: [PATCH 1/3] stop applying gen4 bounds to gen5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gen5 v18's gravityG is per-axis means, not a gravity vector, but it was being checked against gen4's magSq window of [0.25, 3.24]. that rejects the whole record, so on real gen5 hardware a hard workout would silently produce no 1hz rows at all. an unusable accel costs the accel now, not the record. also frame.decodable, and the r10 historical rr path. (the dangerousCmds/_write guard i originally described here is in edge, not this repo — there is no _write in protocol.) --- lib/src/control.dart | 31 +++++++++++++++- lib/src/gen5_records.dart | 65 ++++++++++++++++----------------- test/decode_integrity_test.dart | 62 ++++++++++++++++++++----------- 3 files changed, 101 insertions(+), 57 deletions(-) diff --git a/lib/src/control.dart b/lib/src/control.dart index 5a5162f..6b3e731 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) ───────────────────────────── diff --git a/lib/src/gen5_records.dart b/lib/src/gen5_records.dart index ca6c80e..cb41fcf 100644 --- a/lib/src/gen5_records.dart +++ b/lib/src/gen5_records.dart @@ -234,14 +234,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). @@ -483,34 +485,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". + // 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. // - // 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. - // - // 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 +525,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], diff --git a/test/decode_integrity_test.dart b/test/decode_integrity_test.dart index 1734338..5e25d2a 100644 --- a/test/decode_integrity_test.dart +++ b/test/decode_integrity_test.dart @@ -87,35 +87,53 @@ 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('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, 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); + 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); + }); - // 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); + 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('a good accel still decodes the whole record', () { From e33e53a9b6a5ac016b5422f2c571e0007ba4421f Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:32:33 +0530 Subject: [PATCH 2/3] stop reading v24's field map over bytes that aren't v24's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the same rule records.dart already applies to rr_count — read the optical block only where the field map is confirmed — was applied to exactly one field. twelve lines below it, ppg, skin contact, the two spo2 channels, skin temp and ambient were read at v24 offsets for EVERY version, including v7 and v18, whose different hr offsets are themselves the proof the layout isn't v24's. so a band serving v18 produced a relative-odi number computed from bytes nobody has ever verified are the optical channels. absent now for unconfirmed versions. hello was inventing two flags. wristOn came from payload[116], which is inside the all-zero tail of all three real captured bodies, so it stamped a confident false on every connect and clobbered the true we'd learned from WRIST_ON. charging came from payload[5], which is the zero high byte of the battery u32 the scan resolves at [3] — false for every battery under 6553%. both null now; wear comes from the event and the realtime wearing bit. gen5 v18 rejected the whole record on one out-of-range hr byte, so a single artefact bpm also threw away that second's beats, skin temp, steps and sleep state — and then the band trimmed them. it costs the hr now, not the record, same as the accel rule thirty lines below. signalQualityLogVariance is nullable so a non-finite float stops poisoning every mean it lands in. the console reassembler cleared the buffer on an index gap before the caller could act on its own false return, so the run it claimed to flush was gone. parks it for the next flush now. three comments were describing code that doesn't exist: setClock's [4:8] is a load-bearing 1/32768s subsecond not pad, set_config emits 65 bytes led by a revision not 40 led by the name (and 120 is a persistent nvm write, so building from that comment was an un-undoable wrong write), and the alarm slot range is per-generation. --- lib/src/commands.dart | 16 ++++-- lib/src/constants.dart | 6 ++- lib/src/control.dart | 40 ++++++++++++--- lib/src/framing.dart | 5 ++ lib/src/gen5_records.dart | 23 +++++++-- lib/src/records.dart | 26 +++++++--- test/control_plane_offsets_test.dart | 12 +++++ test/decode_integrity_test.dart | 75 ++++++++++++++++++++++++++++ test/hello_test.dart | 12 +++++ 9 files changed, 187 insertions(+), 28 deletions(-) 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..e7dc373 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; diff --git a/lib/src/control.dart b/lib/src/control.dart index 6b3e731..46b63e8 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -261,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({ @@ -325,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 @@ -906,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); @@ -927,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; 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 cb41fcf..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 @@ -349,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, @@ -471,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. @@ -540,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 5e25d2a..0247854 100644 --- a/test/decode_integrity_test.dart +++ b/test/decode_integrity_test.dart @@ -87,6 +87,44 @@ void main() { }); }); + 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', () { @@ -136,6 +174,43 @@ void main() { 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, 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', () { final inner = Uint8List(kGen5V18InnerLen); inner[0] = PacketType.historicalData; 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); From de6df835cebe2c65f2a6142e3046b71a5bd57fd5 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:15:34 +0530 Subject: [PATCH 3/3] decodeFrame dropped r10 beat intervals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseR10Lite accepts them and the short realtime-hr branch emits them, so anything reading live r10 through decodeFrame instead of live.dart just lost them. also the setFfValue note still said 40-byte body — that's the form the strap rejects, commands.dart has said 65 for a while. --- lib/src/constants.dart | 10 +++++++--- lib/src/control.dart | 12 ++++++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/lib/src/constants.dart b/lib/src/constants.dart index e7dc373..5f04007 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -177,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 46b63e8..adf556f 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -1070,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).