diff --git a/lib/src/records.dart b/lib/src/records.dart index 603d360..34202e6 100644 --- a/lib/src/records.dart +++ b/lib/src/records.dart @@ -61,6 +61,9 @@ class R24 { /// do NOT apply a correction factor here: a scale derived from one corpus /// would be a fabricated calibration, and it would silently move every value /// downstream code has already stored. + /// + /// EMPTY on v25 — that layout has no accelerometer field we can read (see + /// [_parseV25]). Empty means absent; it does not mean a still wrist. final List accelG; /// The u8 at inner[51] (frame[55]). @@ -231,7 +234,8 @@ const int kMinRrMs = 200; const int kMaxRrMs = 2500; /// The historical-record layout versions this package has a field map for. -/// v25 has its own dedicated layout ([_parseV25]); the rest share the v24 field +/// v25 has its own dedicated layout ([_parseV25], timestamp only); the rest +/// share the v24 field /// map with a per-version HR offset ([_hrOffsetByVersion]). Used by live.dart's /// `decodeRecord` so every version [parseR24] can decode is actually routed to /// it (v12 in particular is real firmware and used to fall through to null, @@ -250,37 +254,35 @@ double _pow10(int n) { ByteData _view(Uint8List b) => b.buffer.asByteData(b.offsetInBytes, b.lengthInBytes); -double _gravI16(Uint8List inner, int offset) { - final v = _view(inner).getInt16(offset, Endian.little); - return _round(v / 16384.0, 4); -} - -/// Decode a v25 (PPG-derived) historical record. Unlike v24, the v25 field map -/// comes purely from our own device captures — it has no independent -/// cross-reference, so it is LOWER-CONFIDENCE: we decode timing + gravity and -/// deliberately leave HR at 0 (v25 carries no honest 1 Hz beat). It is gated on -/// a gravity-magnitude sanity check to avoid emitting garbage. +/// Decode a v25 historical record. v25 is NOT a 1 Hz biometric record: it is a +/// raw ~24 Hz PPG waveform, sent in 13–27 s bursts roughly every 20 minutes. +/// The only field solved in it is the unix second at inner[7:11]; the waveform +/// itself is left whole in [R24.rawTail]. +/// +/// So everything else comes back absent, and that is deliberate: +/// +/// - HR stays 0 — v25 carries no beat. Running a peak/autocorrelation pass +/// over concatenated bursts manufactures a ~60 bpm peak out of the burst +/// cadence itself. +/// - accelG is EMPTY, not (0,0,0). We used to read a "gravity" vector at +/// inner[69/71/73] as i16/16384 and gate it on |g| ≈ 1. It is not gravity. +/// Measured over the real v25 corpus: the "z" axis takes THREE distinct +/// values in the whole sample, the "x" is the upper half of an f32 that +/// starts two bytes earlier, and per-axis correlation against the v24 +/// gravity vector for the SAME second is 0.16 / 0.22 / 0.06. A vector that +/// near-constant reads downstream as a perfectly still wrist, so emitting +/// it fabricated immobility — for hours a day, every day. Do not put it +/// back, and do not go looking for a different accel offset here. +/// - the optical fields stay 0 — no red/IR scalar offset is solved, and no +/// true SpO2% decode exists. +/// +/// Respiratory rate, HRV, SpO2 and perfusion index were each tested against +/// this waveform and each failed. Nothing derived belongs in this function. R24? _parseV25(Uint8List inner) { + // Only inner[3:11] is read below, but a real v25 burst record is ~76 bytes; + // anything shorter is a truncated frame, and this floor keeps them out. if (inner.length < 75) return null; final view = _view(inner); - // The documented v25 layout uses frame-absolute offsets. Our decoder - // receives the inner record starting at packet type 0x2f, so subtract the - // 4-byte transport prefix: unix 11->7 and gravity 73/75/77->69/71/73. - // - // What is KNOWN for v25: - // - unix is stable at inner[7:11] - // - gravity is stable at inner[69/71/73] as i16/16384 - // What is NOT solved here: - // - exact red/IR scalar offsets inside the v25 optical region - // - any true SpO2% decode - // - // So v25 intentionally surfaces motion + time only and leaves the optical - // fields zero rather than inventing a bogus decode. - final gx = _gravI16(inner, 69); - final gy = _gravI16(inner, 71); - final gz = _gravI16(inner, 73); - final mag = (gx * gx + gy * gy + gz * gz); - if (mag < 0.25 || mag > 2.25) return null; // ~0.5g..1.5g return R24( histVersion: 25, tsEpoch: view.getUint32(7, Endian.little), @@ -291,7 +293,7 @@ R24? _parseV25(Uint8List inner) { rrIntervalsMs: const [], ppgGreen: 0, ppgRedIr: 0, - accelG: [gx, gy, gz], + accelG: const [], skinContact: 0, spo2RedRaw: 0, spo2IrRaw: 0, @@ -335,7 +337,8 @@ bool _physiologicallyPlausible(List accelG, int hr) { /// packet-type byte. /// /// Routing: -/// - v25 → the PPG-derived layout ([_parseV25]). +/// - v25 → the 24 Hz PPG-waveform layout ([_parseV25]): timestamp only, with +/// HR 0 and accelG empty. /// - v24 / v12 → our hardware-validated field map, returned as-is. /// - 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 diff --git a/test/v25_test.dart b/test/v25_test.dart index 8a38da5..1bfe588 100644 --- a/test/v25_test.dart +++ b/test/v25_test.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:openstrap_protocol/openstrap_protocol.dart'; import 'package:test/test.dart'; @@ -13,7 +11,7 @@ void main() { 'aa50000c2f1900026800007fff2a6a38390900729103003608a2fd0104850d4f1bd21aa60f080d850edb116b0f160b7d063f06ab04d5041704a4045f04f003f5ffd7ff7efe73ffa8b2333e9003010000fa54e5e9', ]; - test('parseR24 auto-routes v25 and decodes unix + gravity', () { + test('parseR24 auto-routes v25 and decodes the unix second only', () { final parsed = [ for (final hex in records) parseR24(hexToBytes(_innerHex(hex))) ]; @@ -28,15 +26,25 @@ void main() { expect(r.rrIntervalsMs, isEmpty); expect(r.spo2RedRaw, 0); expect(r.spo2IrRaw, 0); - final mag = math.sqrt( - r.accelG[0] * r.accelG[0] + - r.accelG[1] * r.accelG[1] + - r.accelG[2] * r.accelG[2], - ); - expect(mag, inInclusiveRange(0.8, 1.2)); + // No accel on v25. The old inner[69/71/73] read looked like a ~1 g + // vector on exactly these records, which is why it survived so long: + // across the real corpus its "z" has three distinct values, its "x" is + // half an f32, and it correlates 0.16/0.22/0.06 with the v24 gravity + // for the same second. Absent, NOT (0,0,0) — a zero vector is a still + // wrist to every motion consumer. + expect(r.accelG, isEmpty); } }); + test('the waveform payload and its timestamp are untouched', () { + final r = parseR24(hexToBytes(_innerHex(records[0])))!; + // rawTail is inner[13:], i.e. the PPG burst itself. Compare the WHOLE + // slice, not `endsWith` — that also passes if the decoder silently drops + // leading waveform bytes, which is the failure worth catching. + expect(r.rawTail, isNotEmpty); + expect(r.rawTail, _innerHex(records[0]).substring(13 * 2)); + }); + test('decodeRecord surfaces v25 timestamps', () { final decoded = [for (final hex in records) decodeRecord(_innerHex(hex))]; expect(decoded.every((r) => r != null), isTrue);