Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/openstrap_protocol.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export 'src/gen5_records.dart'
kGen5V20InnerLen,
kGen5V21InnerLen,
kGen5V26MinInnerLen,
kGen5V26MinInnerLenWithMeta,
parseGen5Historical;
export 'src/live.dart'
show
Expand Down
102 changes: 100 additions & 2 deletions lib/src/gen5_records.dart
Original file line number Diff line number Diff line change
Expand Up @@ -635,23 +635,99 @@ class Gen5V21Decoder implements Gen5RecordDecoder {
class Gen5PpgWaveform extends Gen5HistoricalRecord {
final int layoutMarker;

/// Uninterpreted staging byte @ inner[11] (frame-abs 19).
/// Low byte of [segmentId] @ inner[11] (frame-abs 19).
///
/// This offset is a u16, not a byte: inner[12] is nonzero on 99% of real
/// records, so reading one byte here discards most of the value. Prefer
/// [segmentId].
@Deprecated('frame-abs 19 is a u16 — use segmentId')
final int rawByte19;

/// u16 LE @ inner[11:13] (frame-abs 19), constant across the records of one
/// burst.
///
/// The value is an integer `k` in 0..99 packed as a Q15 fraction:
/// `segmentId == (k * 32768) ~/ 100` holds for every record observed. Use
/// [segmentIndex] for `k`. What `k` counts is not established, so it is
/// exposed without a claim.
final int segmentId;

/// Per-burst counter (NOT a channel/LED id — ranges past 26 in the
/// reference corpus). @ inner[13] (frame-abs 21).
final int burstIndex;

/// u16 LE @ inner[15:17] (frame-abs 23). No meaning established — it does
/// not track heart rate, motion, the waveform, gain or [signalMetric].
/// Exposed raw, don't consume.
final int frontEndMetaRaw;

/// Acquisition-channel index @ inner[17] (frame-abs 25), 0..7.
///
/// **The band multiplexes several optical channels through one v26 stream**,
/// and this byte is what separates them. Consecutive records in a single
/// burst carry different values, each with its own gain configuration
/// ([gainIndex]/[gainSetting]) and its own amplitude, and each carries an
/// independent pulse. Without splitting on it, a burst is several channels
/// interleaved, which is not a signal.
///
/// Values outside 0..7 occur on about 0.2% of records (0xFD..0xFF). They are
/// not obviously invalid — the waveform still looks like a pulse — but they
/// are not a channel index either, so use [subChannelKnown].
final int subChannel;

/// [subChannel] gated to the 0..7 range, null otherwise — the honest getter,
/// mirroring [Gen5HistorySample.activityClassKnown].
int? get subChannelKnown =>
(subChannel >= 0 && subChannel <= 7) ? subChannel : null;

/// `k` in 0..99 recovered from [segmentId]'s Q15 packing, or null if this
/// record's value doesn't fit the packing.
int? get segmentIndex {
final k = ((segmentId * 100) / 32768).round();
return (k >= 0 && k <= 99 && (k * 32768) ~/ 100 == segmentId) ? k : null;
}

/// f32 LE @ inner[67:71] (frame-abs 75). Tracks with [flagA]/[flagB] as a
/// per-record signal-quality indicator where LOW means a clean record, but
/// the scale is unpinned. Exposed raw.
///
/// Null when the record is too short to carry the trailing metadata block
/// (see [kGen5V26MinInnerLenWithMeta]) — never a stand-in value.
final double? signalMetric;

/// Front-end gain configuration @ inner[71] / inner[72] (frame-abs 79/80).
/// Each [subChannel] runs a characteristic gain, adjusted within a range.
/// Null on a record too short to carry it.
final int? gainSetting;
final int? gainIndex;

/// Raw flag bytes @ inner[73] / inner[74] (frame-abs 81/82). Roughly
/// complementary, and [signalMetric] is about an order of magnitude lower
/// when [flagB] is set. Meaning otherwise unestablished — exposed raw.
/// Null on a record too short to carry them.
final int? flagA;
final int? flagB;

/// Raw AC-coupled ADC samples, no physical unit. Always 24 in practice.
/// Every channel is DC-removed on the band: the per-record sample mean is
/// ~0 for all values of [subChannel].
final List<int> ppgWaveform;

const Gen5PpgWaveform({
required super.histVersion,
required super.recordIndex,
required super.unix,
required this.layoutMarker,
required this.rawByte19,
@Deprecated('frame-abs 19 is a u16 — use segmentId') required this.rawByte19,
required this.segmentId,
required this.burstIndex,
required this.frontEndMetaRaw,
required this.subChannel,
required this.signalMetric,
required this.gainSetting,
required this.gainIndex,
required this.flagA,
required this.flagB,
required this.ppgWaveform,
});
}
Expand All @@ -664,6 +740,15 @@ const int _kV26SamplesStart = 19; // frame-abs 27
/// practice — this is a floor, not the exact match v20/v21 use.
const int kGen5V26MinInnerLen = _kV26SamplesStart + 2 * _kV26SampleCount; // 67

/// Minimum inner length to also read the metadata block that follows the
/// waveform (signal metric, gain, flags — the last is inner[74]).
///
/// Kept separate from [kGen5V26MinInnerLen] on purpose: a record shorter than
/// this still decodes its waveform, and the trailing fields come back null
/// rather than the record being rejected. Every real record observed carries
/// an inner length of 76.
const int kGen5V26MinInnerLenWithMeta = 75;

class Gen5V26Decoder implements Gen5RecordDecoder {
const Gen5V26Decoder();

Expand All @@ -685,6 +770,7 @@ class Gen5V26Decoder implements Gen5RecordDecoder {
for (int i = 0; i < _kV26SampleCount; i++) {
samples.add(v.getInt16(_kV26SamplesStart + 2 * i, Endian.little));
}
final hasMeta = inner.length >= kGen5V26MinInnerLenWithMeta;

return Gen5PpgWaveform(
histVersion: hdr.version,
Expand All @@ -697,8 +783,20 @@ class Gen5V26Decoder implements Gen5RecordDecoder {
recordIndex: v.getUint16(3, Endian.little),
unix: hdr.unix,
layoutMarker: hdr.layoutMarker,
// ignore: deprecated_member_use_from_same_package
rawByte19: inner[11],
segmentId: v.getUint16(11, Endian.little),
burstIndex: inner[13],
frontEndMetaRaw: v.getUint16(15, Endian.little),
subChannel: inner[17],
// The metadata block sits AFTER the waveform, so a short record can
// legitimately lack it. Null out rather than reject the record or
// invent a value.
signalMetric: hasMeta ? v.getFloat32(67, Endian.little) : null,
gainSetting: hasMeta ? inner[71] : null,
gainIndex: hasMeta ? inner[72] : null,
flagA: hasMeta ? inner[73] : null,
flagB: hasMeta ? inner[74] : null,
ppgWaveform: samples,
);
}
Expand Down
76 changes: 76 additions & 0 deletions test/gen5_historical_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,82 @@ void main() {
});
});

group('parseGen5Historical — v26 per-record metadata', () {
// The same real v26 fixture as above.
final frame = hex(
'aa015000010035412f1a80ad418401f0a3266aae470100c3c5050068faccfa8dfb46f'
'c8bfd4cfebafedafe6dff56ffd5fffbff37ff6afce5f9d7f8dffa5efc98fddbfe5afe8'
'4fe15ff5cff405fb33c50080101006cb67c17',
);

Gen5PpgWaveform decode(Uint8List f) {
final parsed = parseFrame(f, profile: BandProfile.gen5)!;
expect(parsed.valid, isTrue);
return parseGen5Historical(parsed.inner) as Gen5PpgWaveform;
}

test('frame-abs 19 is a u16, and the old byte read loses the high half',
() {
final wf = decode(frame);
expect(wf.segmentId, 18350);
// The deprecated byte read returns 18350 & 0xFF — the high byte (71)
// is discarded. On real records that byte is nonzero 99% of the time.
// ignore: deprecated_member_use_from_same_package
expect(wf.rawByte19, 174);
// ignore: deprecated_member_use_from_same_package
expect(wf.rawByte19, wf.segmentId & 0xFF);
expect(wf.segmentId >> 8, isNonZero);
});

test('segmentId is an integer 0..99 packed as a Q15 fraction', () {
final wf = decode(frame);
expect(wf.segmentIndex, 56);
expect((56 * 32768) ~/ 100, wf.segmentId);
});

test('sub-channel, gain and the trailing metadata block decode', () {
final wf = decode(frame);
expect(wf.subChannel, 5);
expect(wf.subChannelKnown, 5); // inside 0..7
expect(wf.burstIndex, 1); // distinct from subChannel
expect(wf.frontEndMetaRaw, 50627);
expect(wf.signalMetric, closeTo(0.0219, 1e-4));
expect(wf.gainSetting, 80);
expect(wf.gainIndex, 8);
expect(wf.flagA, 1);
expect(wf.flagB, 1);
});

test('sub-channel outside 0..7 is not fabricated into a channel', () {
final parsed = parseFrame(frame, profile: BandProfile.gen5)!;
for (final bad in [0xFD, 0xFE, 0xFF]) {
final inner = Uint8List.fromList(parsed.inner);
inner[17] = bad;
final wf = parseGen5Historical(inner) as Gen5PpgWaveform;
expect(wf.subChannel, bad); // raw byte preserved
expect(wf.subChannelKnown, isNull); // but not offered as a channel
}
});

test('a record too short for the trailing block still decodes its waveform',
() {
final parsed = parseFrame(frame, profile: BandProfile.gen5)!;
final short = Uint8List.fromList(
parsed.inner.sublist(0, kGen5V26MinInnerLen),
);
final wf = parseGen5Historical(short) as Gen5PpgWaveform;
expect(wf.ppgWaveform.length, 24);
expect(wf.subChannel, 5); // sits before the samples, still readable
expect(wf.segmentId, 18350);
// The block after the waveform is absent — null, never a stand-in.
expect(wf.signalMetric, isNull);
expect(wf.gainSetting, isNull);
expect(wf.gainIndex, isNull);
expect(wf.flagA, isNull);
expect(wf.flagB, isNull);
});
});

group(
'parseGen5Historical — v21 IMU deep buffer (structural, offsets from §1.5)',
() {
Expand Down