diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index ac3903d..d092b30 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -533,6 +533,75 @@ bool firmwareSupportsOffbandCaplog(int? offbandCaps, int? firmwareVerCode) => (offbandCaps & offbandCapCaplog) != 0 && (firmwareVerCode ?? 0) >= 17; +/// Packet-hash query capability (firmware #611, cap byte 2 bit 3). Lets the +/// client ask firmware for the authoritative on-air hash of a channel message +/// it sent, keyed by (msg_timestamp, channel_idx), to correlate against +/// CoreScope observer counts (#524). Allocation provisional until the firmware +/// PR merges; confirm against it before release. +const int offbandCap2PktHash = 0x08; + +/// True iff this radio supports the 0xC6 packet-hash query. Requires both the +/// cap-byte-2 bit AND FIRMWARE_VER_CODE >= 22. The client must never emit 0xC6 +/// unless this is true. +bool firmwareSupportsPktHash(int? offbandCaps2, int? firmwareVerCode) => + offbandCaps2 != null && + (offbandCaps2 & offbandCap2PktHash) != 0 && + (firmwareVerCode ?? 0) >= 22; + +/// 0xC6 CMD_OFFBAND_PKT_HASH (firmware #611). Client-issued query, never a push; +/// the stock channel-send OK reply is untouched. +const int cmdOffbandPktHash = 0xC6; +const int pktHashReqGet = 0x01; +const int pktHashRespGet = 0x01; +const int pktHashRespErr = 0x7F; + +/// Build a 0xC6 GET request for the hash of a sent channel message, keyed by +/// the (msg_timestamp, channel_idx) the client used in CMD_SEND_CHANNEL_TXT_MSG. +/// Wire: [0xC6][0x01][ts:4 LE][chan:1] = 7 bytes. +Uint8List buildOffbandPktHashGetFrame(int msgTimestamp, int channelIdx) { + final frame = Uint8List(7); + frame[0] = cmdOffbandPktHash; + frame[1] = pktHashReqGet; + ByteData.sublistView(frame, 2, 6).setUint32(0, msgTimestamp, Endian.little); + frame[6] = channelIdx & 0xFF; + return frame; +} + +/// A parsed 0xC6 success reply: the on-air packet hash plus the echoed key, +/// so a reply can be matched to its request without relying on ordering. +class OffbandPktHash { + const OffbandPktHash({ + required this.timestamp, + required this.channelIdx, + required this.hashHex, + }); + + /// msg_timestamp echoed from the request. + final int timestamp; + + /// channel_idx echoed from the request. + final int channelIdx; + + /// 16 lowercase hex chars, matching CoreScope's hash and the client's own + /// [_computePacketHash] format. + final String hashHex; +} + +/// Parse a 0xC6 reply. Returns the hash on a success reply +/// ([0xC6][0x01][ts:4][chan:1][hash:8], 15 bytes), or null on the error reply +/// ([0xC6][0x7F][reason]) or any malformed frame. +OffbandPktHash? parseOffbandPktHashReply(Uint8List frame) { + if (frame.length < 15) return null; + if (frame[0] != cmdOffbandPktHash || frame[1] != pktHashRespGet) return null; + final ts = ByteData.sublistView(frame, 2, 6).getUint32(0, Endian.little); + final chan = frame[6]; + final hashHex = frame + .sublist(7, 15) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + return OffbandPktHash(timestamp: ts, channelIdx: chan, hashHex: hashHex); +} + const int statsTypeCore = 0; const int statsTypeRadio = 1; const int statsTypePackets = 2; diff --git a/test/connector/pkt_hash_protocol_test.dart b/test/connector/pkt_hash_protocol_test.dart new file mode 100644 index 0000000..ce6b234 --- /dev/null +++ b/test/connector/pkt_hash_protocol_test.dart @@ -0,0 +1,78 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; + +void main() { + group('0xC6 pkt-hash builder', () { + test('builds a 7-byte GET with little-endian timestamp', () { + final frame = buildOffbandPktHashGetFrame(0x11223344, 7); + expect(frame, [0xC6, 0x01, 0x44, 0x33, 0x22, 0x11, 0x07]); + }); + + test('masks channel_idx to one byte', () { + final frame = buildOffbandPktHashGetFrame(0, 0x1FF); + expect(frame[6], 0xFF); + }); + }); + + group('0xC6 pkt-hash reply parser', () { + Uint8List reply(int ts, int chan, List hash8) { + final f = Uint8List(15); + f[0] = cmdOffbandPktHash; + f[1] = pktHashRespGet; + ByteData.sublistView(f, 2, 6).setUint32(0, ts, Endian.little); + f[6] = chan; + f.setRange(7, 15, hash8); + return f; + } + + test('parses a success reply and echoes the key', () { + final parsed = parseOffbandPktHashReply( + reply(0x11223344, 3, [0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x0a, 0xff]), + ); + expect(parsed, isNotNull); + expect(parsed!.timestamp, 0x11223344); + expect(parsed.channelIdx, 3); + // 16 lowercase hex chars, matching CoreScope + client _computePacketHash. + expect(parsed.hashHex, 'deadbeef01020aff'); + }); + + test('returns null on the error reply', () { + final err = Uint8List.fromList([cmdOffbandPktHash, pktHashRespErr, 1]); + expect(parseOffbandPktHashReply(err), isNull); + }); + + test('returns null on a short/malformed frame', () { + expect( + parseOffbandPktHashReply(Uint8List.fromList([0xC6, 0x01, 0, 0])), + isNull, + ); + expect(parseOffbandPktHashReply(Uint8List(0)), isNull); + }); + + test('returns null when the opcode is wrong', () { + final f = reply(1, 1, List.filled(8, 0)); + f[0] = 0xC4; // caplog, not pkt-hash + expect(parseOffbandPktHashReply(f), isNull); + }); + }); + + group('firmwareSupportsPktHash gate', () { + test('requires both the cap bit and ver >= 22', () { + expect(firmwareSupportsPktHash(0x08, 22), isTrue); + expect(firmwareSupportsPktHash(0x08, 23), isTrue); + expect(firmwareSupportsPktHash(0x08, 21), isFalse); // version too low + expect(firmwareSupportsPktHash(0x00, 22), isFalse); // bit clear + expect(firmwareSupportsPktHash(null, 22), isFalse); // no byte 2 + expect(firmwareSupportsPktHash(0x08, null), isFalse); // no version + }); + + test('ignores unrelated bits', () { + // 0x0A = button-matrix (0x02) + pkt-hash (0x08) both set. + expect(firmwareSupportsPktHash(0x0A, 22), isTrue); + // Only notify-scope (0x01) set: pkt-hash bit clear. + expect(firmwareSupportsPktHash(0x01, 22), isFalse); + }); + }); +}