feat(#550): 0xC6 pkt-hash protocol layer + cap2 gate

Builder/parser for the client-issued 0xC6 CMD_OFFBAND_PKT_HASH query (per
firmware #611 contract) and firmwareSupportsPktHash (cap2 0x08 + ver >= 22).
Inert until firmware advertises the capability. Part of epic #524.

Agent: QuietSnow (session 31eaba02)
pull/556/head
Strycher 2 months ago
parent d79dfffad3
commit f2feb0863a

@ -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;

@ -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<int> 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);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.