diff --git a/lib/src/control.dart b/lib/src/control.dart index ee414a2..d802dc8 100644 --- a/lib/src/control.dart +++ b/lib/src/control.dart @@ -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 ────────────────────────────────── @@ -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 @@ -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) { diff --git a/lib/src/garmin.dart b/lib/src/garmin.dart index 8889489..9ed4b8d 100644 --- a/lib/src/garmin.dart +++ b/lib/src/garmin.dart @@ -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 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(); @@ -139,13 +143,14 @@ GarminMlrPacket? garminDecodeMlr(List 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(); } diff --git a/test/decode_integrity_test.dart b/test/decode_integrity_test.dart index 0247854..1de515f 100644 --- a/test/decode_integrity_test.dart +++ b/test/decode_integrity_test.dart @@ -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]) { diff --git a/test/garmin_test.dart b/test/garmin_test.dart index 05fad1c..5b8e10c 100644 --- a/test/garmin_test.dart +++ b/test/garmin_test.dart @@ -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()); + 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', () {