diff --git a/documentation/routing-paths.md b/documentation/routing-paths.md index f1480841..1db0eed2 100644 --- a/documentation/routing-paths.md +++ b/documentation/routing-paths.md @@ -18,18 +18,18 @@ The device capability determines the hash width (number of bytes per hop): ### Device Capability Detection -On device connection, the app reads the firmware capability to set `pathHashByteWidth` reported by the device. The device encodes a "mode" value (0..3) which maps to `width = mode + 1` (1..4 bytes per hop). The connector uses this width as the authoritative send width when composing paths for that device. +On device connection, the app reads the firmware capability to set `pathHashByteWidth`: ```dart // Read from device info response (offset 81) final modeRaw = firmwareBytes.length >= 82 - ? (firmwareBytes[81] & 0xFF) - : 0; -final mode = modeRaw.clamp(0, 3); -_pathHashByteWidth = mode + 1; // 1..4 bytes per hop + ? (firmwareBytes[81] & 0xFF) + : 0; +final mode = modeRaw.clamp(0, 2); +_pathHashByteWidth = mode + 1; // 1, 2, or 3 bytes per hop ``` -Note: the app now supports up to 4 bytes per hop when reported by devices. UI validation for user-entered paths still uses the device's configured `pathHashByteWidth` for outbound paths, but inbound packets are interpreted per-packet (see below). +The current implementation intentionally clamps to modes `0..2`, so the supported hop width is `1..3` bytes. Do not document 4-byte hops unless the connector implementation is widened first. ### Path Data Structure @@ -56,24 +56,6 @@ Use this consistently when displaying hop counts in UI. Do not treat `pathLength - **Direct messages**: Extract path from decrypted payload to trace sender route. - **Channel messages**: Decrypt hop-by-hop routing chain from payload; the header carries the encoded byte length for the path blob, not the derived hop count. -### Packet-aware parsing (incoming packets) - -Inbound frames may encode the effective hop width inside the path bytes themselves (top two bits of the first path byte). To avoid misinterpreting a 1-byte packet as a 2-byte path when the node configuration differs, the client detects the packet's width and uses it when splitting and matching hops for that specific packet. - -Use the per-packet detection for display, contact lookups and inferred-position calculations. Continue to use the device's configured `pathHashByteWidth` for composing and validating outbound paths. - -The helper used by the client implements detection equivalent to meshcore_py: - -```dart -static int detectPathHashWidth(List pathBytes, {int fallback = 1}) { - if (pathBytes.isEmpty) return fallback.clamp(1,4); - final first = pathBytes[0]; - final mode = ((first & 0xC0) >> 6) & 0x03; - return (mode + 1).clamp(1,4); -} -``` - -This ensures that display and matching are packet-aware and robust across mixed-width networks. - **Contact storage**: Path length in byte 32, raw path bytes in bytes 33-96, grouped by detected `pathHashByteWidth`. ### UI Hop Count Display diff --git a/lib/helpers/path_helper.dart b/lib/helpers/path_helper.dart index c5cb21f1..3109de90 100644 --- a/lib/helpers/path_helper.dart +++ b/lib/helpers/path_helper.dart @@ -32,21 +32,6 @@ class PathHelper { return hops; } - /// Detect the path hash width encoded in a packet's path bytes. - /// - /// MeshCore packets encode a "mode" in the high bits of the path bytes - /// (same rule used by meshcore_py): mode = ((firstByte & 0xC0) >> 6), - /// width = mode + 1 (yielding 1..4). If the packet is empty or detection - /// fails, `fallback` is returned (clamped to 1..4). - static int detectPathHashWidth(List pathBytes, {int fallback = 1}) { - final fb = fallback.clamp(1, 4); - if (pathBytes.isEmpty) return fb; - final first = pathBytes[0] & 0xFF; - final mode = (first & 0xC0) >> 6; - final width = (mode & 0x03) + 1; - return width.clamp(1, 4); - } - /// Resolves path bytes to contact names, supporting multi-byte hash widths. /// Groups path bytes according to [hashByteWidth]: diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 64aedb58..fcd50861 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -36,7 +36,6 @@ import '../widgets/translated_message_content.dart'; import '../widgets/unread_divider.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; -import '../helpers/path_helper.dart'; class ChannelChatScreen extends StatefulWidget { final Channel channel; @@ -1434,12 +1433,8 @@ class _ChannelChatScreenState extends State { } String _formatPathPrefixes(Uint8List pathBytes) { - final width = PathHelper.detectPathHashWidth( - pathBytes, - fallback: context.read().pathHashByteWidth, - ); - return PathHelper.splitPathBytes(pathBytes, width) - .map(PathHelper.formatHopHex) + return pathBytes + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(','); } } diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index cb6a8d23..328acf31 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -45,16 +45,16 @@ class ChannelMessagePathScreen extends StatelessWidget { final hasHopDetails = primaryPath.isNotEmpty; // Convert path byte length to hop count based on device width - final width = primaryPath.isNotEmpty - ? PathHelper.detectPathHashWidth(primaryPath, fallback: connector.pathHashByteWidth) - : connector.pathHashByteWidth.clamp(1, 4); + final width = connector.pathHashByteWidth.clamp(1, 4); final observedHopCount = _hopCountFromBytes(primaryPath.length, width); final reportedHopCount = message.pathLength == null ? null : (message.pathLength! < 0 - ? message.pathLength - : _hopCountFromBytes(message.pathLength!, width)); - final effectiveHopCount = observedHopCount > 0 ? observedHopCount : reportedHopCount; + ? message.pathLength + : _hopCountFromBytes(message.pathLength!, width)); + final effectiveHopCount = observedHopCount > 0 + ? observedHopCount + : reportedHopCount; final observedLabel = _formatObservedHops( observedHopCount, @@ -76,11 +76,11 @@ class ChannelMessagePathScreen extends StatelessWidget { title: context.l10n.contacts_repeaterPathTrace, path: primaryPath, flipPathAround: true, - reversePathAround: !(!channelMessage && !message.isOutgoing), - pathHashByteWidth: PathHelper.detectPathHashWidth( - primaryPath, - fallback: context.read().pathHashByteWidth, - ), + reversePathAround: + !(!channelMessage && !message.isOutgoing), + pathHashByteWidth: context + .read() + .pathHashByteWidth, ), ), ), @@ -951,7 +951,8 @@ List<_PathHop> _buildPathHops( AppLocalizations l10n, ) { if (pathBytes.isEmpty) return const []; - final width = PathHelper.detectPathHashWidth(pathBytes, fallback: connector.pathHashByteWidth); + + final width = connector.pathHashByteWidth.clamp(1, 4); final candidatesByHashBytes = >{}; final allContacts = connector.allContacts; diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 62d5772e..f53d1bef 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -114,9 +114,8 @@ class _PathTraceMapScreenState extends State { Contact? _targetContact; String _formatPathPrefixes(Uint8List pathBytes) { - final width = PathHelper.detectPathHashWidth(pathBytes, fallback: widget.pathHashByteWidth); - return PathHelper.splitPathBytes(pathBytes, width) - .map(PathHelper.formatHopHex) + return PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth) + .map(PathHelper.formatHopHex) .join(','); } @@ -215,11 +214,28 @@ class _PathTraceMapScreenState extends State { } final mirroredHops = [...pathHops]; - // Mirror the path without duplicating the pivot hop. - if (pathHops.length > 1) { - mirroredHops.addAll( - pathHops.sublist(0, pathHops.length - 1).reversed, - ); + final isRepeaterOrRoom = widget.targetContact?.type == advTypeRepeater || + widget.targetContact?.type == advTypeRoom; + if (isRepeaterOrRoom) { + final pk = widget.targetContact?.publicKey; + if (pk != null && pk.length >= hopWidth) { + mirroredHops.add(Uint8List.fromList(pk.sublist(0, hopWidth))); + } else { + mirroredHops.add( + Uint8List.fromList( + pk != null && pk.isNotEmpty ? [pk[0]] : const [0], + ), + ); + } + // For repeaters/rooms, include full reversed path + mirroredHops.addAll(pathHops.reversed); + } else { + // For non-repeater/room targets, reverse without duplicating the pivot hop + if (pathHops.length > 1) { + mirroredHops.addAll( + pathHops.sublist(0, pathHops.length - 1).reversed, + ); + } } final traceBytes = []; @@ -327,21 +343,14 @@ class _PathTraceMapScreenState extends State { final buffer = BufferReader(frame); try { buffer.skipBytes(2); // Skip push code and reserved byte - // Read the packed path byte: top2 bits = hash width mode, low6 bits = hop count - final pathByte = buffer.readUInt8(); - final packetPathHashWidth = ((pathByte & 0xC0) >> 6) + 1; - final hopCount = pathByte & 0x3F; - // Skip flag byte + tag (4) + auth code (4) - buffer.skipBytes(5); - buffer.skipBytes(4); - final pathBytes = buffer.readBytes(hopCount * packetPathHashWidth); - - final packetWidth = packetPathHashWidth; + int pathLength = buffer.readUInt8(); + buffer.skipBytes(5); // Skip Flag byte and tag data + buffer.skipBytes(4); // Skip auth code + final pathBytes = buffer.readBytes(pathLength); final pathData = PathHelper.splitPathBytes( pathBytes, - packetWidth, + widget.pathHashByteWidth, ); - List snrData = buffer .readRemainingBytes() .map((snr) => snr.toSigned(8).toDouble() / 4) @@ -359,25 +368,15 @@ class _PathTraceMapScreenState extends State { lastSeen: DateTime.now(), ); if (widget.pathContacts != null) { - final orderedContacts = widget.pathContacts!; - if (orderedContacts.length == pathData.length) { - pathContacts = { - for (var i = 0; i < pathData.length; i++) - _hopKey(pathData[i]): orderedContacts[i], - }; - } else { - final hopWidth = packetWidth.clamp(1, pubKeySize); - pathContacts = { - for (var c in orderedContacts) - if (c.publicKey.length >= hopWidth) - _hopKey(Uint8List.fromList(c.publicKey.sublist(0, hopWidth))): c, - }; - } + final hopWidth = widget.pathHashByteWidth.clamp(1, pubKeySize); + pathContacts = { + for (var c in widget.pathContacts!) + if (c.publicKey.length >= hopWidth) + _hopKey(Uint8List.fromList(c.publicKey.sublist(0, hopWidth))): c, + }; } else { final contacts = connector.allContactsUnfiltered; contacts.where((c) => c.type != advTypeChat).forEach((repeater) { - final hopWidthPreview = pathData.isNotEmpty ? pathData.first.length : packetWidth.clamp(1, pubKeySize); - if (lastContact.latitude != null && lastContact.longitude != null && repeater.hasLocation && @@ -387,17 +386,12 @@ class _PathTraceMapScreenState extends State { LatLng(repeater.latitude!, repeater.longitude!), ) > _maxRepeaterMatchDistanceMeters) { - // skip repeaters that are far away from the last one with known GPS - // (no logging here) return; //skip reapeaters that are far away from the last one with known GPS, to avoid false matches } for (final repeaterData in pathData) { final hopWidth = repeaterData.length; - if (repeater.publicKey.length < hopWidth) continue; - if (listEquals(repeater.publicKey.sublist(0, hopWidth), repeaterData)) { - pathContacts[_hopKey(repeaterData)] = repeater; lastContact = repeater; } @@ -417,7 +411,7 @@ class _PathTraceMapScreenState extends State { (c) => c.hasLocation && c.path.isNotEmpty && - _hopKey(_lastHopChunk(c.path, packetWidth)) == + _hopKey(_lastHopChunk(c.path, widget.pathHashByteWidth)) == hopKey, ) .toList(); @@ -464,7 +458,9 @@ class _PathTraceMapScreenState extends State { (c) => c.hasLocation && c.path.isNotEmpty && - _hopKey(_lastHopChunk(c.path, packetWidth)) == + _hopKey( + _lastHopChunk(c.path, widget.pathHashByteWidth), + ) == lastHopKey, ) .toList(); diff --git a/test/helpers/path_helper_packet_test.dart b/test/helpers/path_helper_packet_test.dart deleted file mode 100644 index a76638c4..00000000 --- a/test/helpers/path_helper_packet_test.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:meshcore_open/helpers/path_helper.dart'; - -void main() { - test('detectPathHashWidth identifies 1,2,3,4-byte widths from first byte', () { - // mode 0 -> width 1 (00xxxxxx) - expect(PathHelper.detectPathHashWidth([0x12]), equals(1)); - - // mode 1 -> width 2 (01xxxxxx) - expect(PathHelper.detectPathHashWidth([0x40]), equals(2)); - expect(PathHelper.detectPathHashWidth([0x7F]), equals(2)); - - // mode 2 -> width 3 (10xxxxxx) - expect(PathHelper.detectPathHashWidth([0x80]), equals(3)); - - // mode 3 -> width 4 (11xxxxxx) - expect(PathHelper.detectPathHashWidth([0xC0]), equals(4)); - }); - - test('packet-aware split uses detected width vs fallback', () { - // Packet has mode=0 (1-byte hops) but fallback is 2 - final packet = [0x01, 0x02, 0x03]; - final detected = PathHelper.detectPathHashWidth(packet, fallback: 2); - expect(detected, equals(1)); - - final hops = PathHelper.splitPathBytes(packet, detected); - expect(hops, hasLength(3)); - expect(hops.first, equals(Uint8List.fromList([0x01]))); - - // Packet with mode=1 (2-byte hops) - final packet2 = [0x40, 0xAA, 0xBB, 0xCC]; - final detected2 = PathHelper.detectPathHashWidth(packet2, fallback: 1); - expect(detected2, equals(2)); - final hops2 = PathHelper.splitPathBytes(packet2, detected2); - expect(hops2, hasLength(2)); - expect(hops2.first, equals(Uint8List.fromList([0x40, 0xAA]))); - }); -}