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
12 changes: 10 additions & 2 deletions lib/src/control.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'band.dart';
import 'constants.dart';
import 'framing.dart';
import 'gen5_records.dart';
import 'live.dart' show kR10MinLength;
import 'records.dart';

// ── little-endian helpers over a byte list ──────────────────────────────────
Expand Down Expand Up @@ -1484,12 +1485,19 @@ Decoded _decodeDataRecord(Uint8List inner,
'data_record', {'rec_type': inner.length > 1 ? inner[1] : -1});
}
final recType = inner.length > 1 ? inner[1] : -1;
// A gen4 historical (0x2F) record is never a live HR packet: inner[1] is its
// layout version, not a record type, and inner[8] sits inside its own u32
// timestamp at [7:11]. Same rule as live.dart — a 0x2F frame is R10 only at
// R10 size, and never goes through the small-packet realtime heuristics.
final historical = inner.isNotEmpty && inner[0] == PacketType.historicalData;
// Live R10 (HR + IMU) — surface HR for the live display. Checked before the
// generic small-packet branch below: a short/lite R10 record (parseR10Lite
// only requires 18 bytes) is still under that branch's 64-byte cutoff and
// would otherwise be swallowed there first, misread by the wrong offsets,
// and never reach this branch at all.
if (recType == Record.r10) {
if (recType == Record.r10 &&
(inner[0] == PacketType.realtimeRawData ||
inner.length >= kR10MinLength)) {
final r = parseR10Lite(inner);
if (r != null) {
// rr_ms too: parseR10Lite already accepted these beats, and the short
Expand All @@ -1511,7 +1519,7 @@ Decoded _decodeDataRecord(Uint8List inner,
}
}
// Compact realtime stream (small packet).
if (inner.length < 64) {
if (inner.length < 64 && !historical) {
if (recType == 2) {
final v2 = parseRealtimeHrV2(inner);
if (v2 != null) {
Expand Down
23 changes: 14 additions & 9 deletions lib/src/garmin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,17 +116,21 @@ class GarminMlrControlOther extends GarminMlrPacket {
const GarminMlrControlOther(this.type);
}

/// Route one raw notification. Null for input too short, or structurally not
/// one of the two shapes this protocol documents (a flagged watch-to-host
/// data frame, or a control frame with byte 0 clear) — everything else lands
/// in one of the [GarminMlrPacket] arms above.
/// Route one raw notification. Null only for input too short to be anything
/// — everything else lands in one of the [GarminMlrPacket] arms above.
GarminMlrPacket? garminDecodeMlr(List<int> data) {
if (data.isEmpty) return null;
if ((data[0] & _kMlrFlag) != 0) {
final handle = (data[0] & _kMlrHandleMask) >> _kMlrHandleShift;
return GarminMlrData(handle, Uint8List.fromList(data.sublist(1)));
}
if (data[0] != 0) return null;
// Byte 0 clear and non-zero is the watch addressing a handle with a bare
// byte (how it answers a handle registered non-reliable, and the only form
// for handles above the 3-bit flagged range).
if (data[0] != 0) {
if (data.length < 2) return null;
return GarminMlrData(data[0], Uint8List.fromList(data.sublist(1)));
}
if (data.length < 2) return null;
final type = data[1];
if (type == _kRespCloseAll) return const GarminCloseAllAck();
Expand All @@ -139,13 +143,14 @@ GarminMlrPacket? garminDecodeMlr(List<int> data) {
return GarminMlrControlOther(type);
}

/// CLOSE_ALL_REQ payload: type(u8) + reserved(u16=0) + client id(i64) +
/// reserved(u8=0). Wipes any handle a previous session left registered.
/// CLOSE_ALL_REQ payload: type(u8) + client id(i64) + reserved(u16=0) +
/// reserved(u8=0). Client id sits right after the type, as in
/// REGISTER_ML_REQ. Wipes any handle a previous session left registered.
Uint8List garminCloseAllRequest() {
final b = ByteData(12)
..setUint8(0, _kReqCloseAll)
..setUint16(1, 0, Endian.little)
..setInt64(3, _kGarminClientId, Endian.little)
..setInt64(1, _kGarminClientId, Endian.little)
..setUint16(9, 0, Endian.little)
..setUint8(11, 0);
return b.buffer.asUint8List();
}
Expand Down
26 changes: 26 additions & 0 deletions test/decode_integrity_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,32 @@ void main() {
});
});

group('decodeFrame never reads live HR out of a gen4 historical record', () {
Frame frame(Uint8List inner) => Frame(inner, true, true);

test('a short 0x2F record with version byte 10 is not R10', () {
final inner = _record(version: 10, length: 96, hr: 90);
final d = decodeFrame(frame(inner));
expect(d.kind, isNot('realtime_hr'));
expect(d.fields['hr'], isNull);
});

test('a truncated 0x2F record is not a compact realtime packet', () {
// inner[8] is a timestamp byte here, not a bpm
final d = decodeFrame(
frame(Uint8List.fromList(_record(version: 24).sublist(0, 40))));
expect(d.kind, isNot('realtime_hr'));
expect(d.fields['hr'], isNull);
});

test('a full-size archived R10 still decodes', () {
final inner = _record(version: 10, length: 1920, hr: 90);
final d = decodeFrame(frame(inner));
expect(d.kind, 'realtime_hr');
expect(d.fields['hr'], 90);
});
});

group('R-R beats are read only where the field map is confirmed', () {
test('v24 and v12 keep their beats', () {
for (final version in [24, 12]) {
Expand Down
22 changes: 19 additions & 3 deletions test/garmin_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,25 @@ void main() {
expect(decoded.payload, [1, 2, 3], reason: 'routing byte 0 must be stripped');
});

test('a non-flagged, non-zero first byte is rejected, not a data frame',
() {
expect(garminDecodeMlr(const [0x03, 0x01, 0x02]), isNull);
test('a bare non-zero handle byte is a data frame on that handle', () {
final decoded = garminDecodeMlr(const [0x01, 0x00, 0x05, 0x01]);
expect(decoded, isA<GarminMlrData>());
expect((decoded as GarminMlrData).handle, 1);
expect(decoded.payload, [0x00, 0x05, 0x01]);
// above the 3-bit flagged range too
expect((garminDecodeMlr(const [0x0c, 0x01]) as GarminMlrData).handle,
0x0c);
expect(garminDecodeMlr(const [0x03]), isNull);
});

test('close-all puts the client id right after the type', () {
final v = ByteData.sublistView(garminCloseAllRequest());
expect(v.getUint8(0), 0x05);
expect(v.getInt64(1, Endian.little), 2);
expect(v.getUint16(9, Endian.little), 0);
// same offset as register-ml
expect(ByteData.sublistView(garminRegisterMlRequest(kGarminServiceGfdi))
.getInt64(1, Endian.little), 2);
});

test('CLOSE_ALL_RESP decodes to the close-all ack', () {
Expand Down
Loading