diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 848ecc9..39b4f2a 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -196,6 +196,54 @@ class ContactRouteCodec { final hopCount = raw & 0x3F; return hopCount * hashSize <= maxPathBytes; } + + /// Converts an encoded MeshCore route descriptor + path into the legacy raw + /// path format expected by the `CMD_SEND_RAW_DATA` (0x19) handler in + /// companion firmware v1.15. + /// + /// The raw-data handler treats the path-length byte as a *literal* hop count + /// (one byte per hop) rather than as an encoded descriptor, so a 1-hop route + /// using 2-byte hashes (descriptor `0x41`) would otherwise be misread as + /// "65 path bytes" and rejected with `ERROR: Unsupported command`. We + /// down-sample every hop to its first hash byte and emit a plain hop count. + /// + /// Routes that are already legacy (hash size 1, descriptor `< 0x40`) pass + /// through unchanged. Unknown/flood descriptors collapse to a zero-hop + /// direct path (raw transport does not support flood routing). + static LegacyRawPath toLegacyRawPath(int descriptor, Uint8List path) { + final raw = toUnsignedDescriptor(descriptor); + if (raw == _unknownDescriptor) { + return const LegacyRawPath(length: 0, path: []); + } + if (raw < 0x40) { + // Hash size 1: the encoded path is already one byte per hop. + final hopCount = raw; + final available = math.min(hopCount, path.length); + return LegacyRawPath( + length: hopCount, + path: Uint8List.fromList(path.sublist(0, available)), + ); + } + final hopCount = raw & 0x3F; + final hashSize = ((raw >> 6) & 0x03) + 1; + final legacy = Uint8List(hopCount); + for (var hop = 0; hop < hopCount; hop++) { + final srcIndex = hop * hashSize; + if (srcIndex < path.length) { + legacy[hop] = path[srcIndex]; + } + } + return LegacyRawPath(length: hopCount, path: legacy); + } +} + +/// Legacy raw-transport path: a literal hop count plus one byte per hop, as +/// expected by the firmware `CMD_SEND_RAW_DATA` handler. +class LegacyRawPath { + const LegacyRawPath({required this.length, required this.path}); + + final int length; + final List path; } extension ContactLocalization on Contact { diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 040d4e9..a8458fa 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -1833,15 +1833,23 @@ class ConnectionProvider with ChangeNotifier { /// Send a raw binary voice packet directly to a contact (cmdSendRawData, code 25). /// Only works for contacts with a known direct route (outPathLen >= 0). + /// + /// Callers pass the contact's *encoded* route descriptor ([routeEncodedPathLen]) + /// and encoded [outPath]. The `CMD_SEND_RAW_DATA` handler in companion firmware + /// v1.15 misinterprets encoded descriptors as a literal path-byte count, so we + /// normalise to the legacy raw path format (literal hop count + one byte per + /// hop) before handing the frame to the transport. See + /// [ContactRouteCodec.toLegacyRawPath]. Future sendRawVoicePacket({ required Uint8List contactPath, required int contactPathLen, required Uint8List payload, }) async { if (!_activeService.isConnected) return; + final legacy = ContactRouteCodec.toLegacyRawPath(contactPathLen, contactPath); await _activeService.sendRawVoicePacket( - contactPathLen: contactPathLen, - contactPath: contactPath, + contactPathLen: legacy.length, + contactPath: Uint8List.fromList(legacy.path), payload: payload, ); } diff --git a/lib/providers/helpers/raw_session_retransmit.dart b/lib/providers/helpers/raw_session_retransmit.dart index 246ae9f..ef669d8 100644 --- a/lib/providers/helpers/raw_session_retransmit.dart +++ b/lib/providers/helpers/raw_session_retransmit.dart @@ -9,6 +9,11 @@ typedef RawPacketSender = required Uint8List payload, }); +/// Pacing between consecutive raw fragments. Firmware v1.15 rejects bursts of +/// raw packets with `ERROR: Table full` when fragments are blasted too quickly, +/// so we space them out to keep the radio's raw transmit queue from overflowing. +const Duration _interFragmentDelay = Duration(milliseconds: 350); + Future serveCachedSessionFragments({ required String providerLabel, required String sessionId, @@ -19,6 +24,7 @@ Future serveCachedSessionFragments({ required Uint8List Function(T fragment) encodeBinary, required RawPacketSender? sendRawPacket, Set? requestedIndices, + Duration interFragmentDelay = _interFragmentDelay, }) async { if (fragments.isEmpty) { debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId'); @@ -56,6 +62,9 @@ Future serveCachedSessionFragments({ continue; } try { + if (servedCount > 0 && interFragmentDelay > Duration.zero) { + await Future.delayed(interFragmentDelay); + } await sendRawPacket( contactPath: requester.outPath, contactPathLen: requester.routeEncodedPathLen, diff --git a/test/models/contact_route_codec_test.dart b/test/models/contact_route_codec_test.dart index f06eae9..d8538f9 100644 --- a/test/models/contact_route_codec_test.dart +++ b/test/models/contact_route_codec_test.dart @@ -114,4 +114,59 @@ void main() { expect(contact.routeSummary, 'Flood/Unknown'); }); }); + + group('ContactRouteCodec.toLegacyRawPath', () { + test('zero-hop direct (0x40) → empty legacy path', () { + final legacy = ContactRouteCodec.toLegacyRawPath(0x40, Uint8List(0)); + + expect(legacy.length, 0); + expect(legacy.path, isEmpty); + }); + + test('1-hop 2-byte hash (0x41) → first byte of the hash', () { + final legacy = ContactRouteCodec.toLegacyRawPath( + 0x41, + Uint8List.fromList([0x02, 0x62]), + ); + + expect(legacy.length, 1); + expect(legacy.path, [0x02]); + }); + + test('2-hop 2-byte hash (0x42) → first byte of each hash', () { + final legacy = ContactRouteCodec.toLegacyRawPath( + 0x42, + Uint8List.fromList([0xAA, 0xBB, 0xCC, 0xDD]), + ); + + expect(legacy.length, 2); + expect(legacy.path, [0xAA, 0xCC]); + }); + + test('signed 3-byte descriptor (-126 / 0x82) down-samples each hop', () { + final outPath = Uint8List(64) + ..setRange(0, 6, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + final legacy = ContactRouteCodec.toLegacyRawPath(-126, outPath); + + expect(legacy.length, 2); + expect(legacy.path, [0xAA, 0xDD]); + }); + + test('legacy 1-byte hops (< 0x40) pass through trimmed', () { + final legacy = ContactRouteCodec.toLegacyRawPath( + 0x03, + Uint8List.fromList([0xAA, 0xBB, 0xCC, 0x00, 0x00]), + ); + + expect(legacy.length, 3); + expect(legacy.path, [0xAA, 0xBB, 0xCC]); + }); + + test('unknown descriptor (0xFF) collapses to direct', () { + final legacy = ContactRouteCodec.toLegacyRawPath(-1, Uint8List(0)); + + expect(legacy.length, 0); + expect(legacy.path, isEmpty); + }); + }); }