diff --git a/Capture d’écran du 2026-05-17 13-48-42.png b/Capture d’écran du 2026-05-17 13-48-42.png new file mode 100644 index 00000000..c984676b Binary files /dev/null and b/Capture d’écran du 2026-05-17 13-48-42.png differ diff --git a/Capture d’écran du 2026-05-17 13-48-55.png b/Capture d’écran du 2026-05-17 13-48-55.png new file mode 100644 index 00000000..bee9b2a1 Binary files /dev/null and b/Capture d’écran du 2026-05-17 13-48-55.png differ diff --git a/Capture d’écran du 2026-05-17 14-04-55.png b/Capture d’écran du 2026-05-17 14-04-55.png new file mode 100644 index 00000000..c9496449 Binary files /dev/null and b/Capture d’écran du 2026-05-17 14-04-55.png differ diff --git a/Capture d’écran du 2026-05-17 14-12-52.png b/Capture d’écran du 2026-05-17 14-12-52.png new file mode 100644 index 00000000..2974491a Binary files /dev/null and b/Capture d’écran du 2026-05-17 14-12-52.png differ diff --git a/Capture d’écran du 2026-05-17 15-00-31.png b/Capture d’écran du 2026-05-17 15-00-31.png new file mode 100644 index 00000000..98e6795b Binary files /dev/null and b/Capture d’écran du 2026-05-17 15-00-31.png differ diff --git a/documentation/routing-paths.md b/documentation/routing-paths.md index 1db0eed2..f1480841 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`: +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. ```dart // Read from device info response (offset 81) final modeRaw = firmwareBytes.length >= 82 - ? (firmwareBytes[81] & 0xFF) - : 0; -final mode = modeRaw.clamp(0, 2); -_pathHashByteWidth = mode + 1; // 1, 2, or 3 bytes per hop + ? (firmwareBytes[81] & 0xFF) + : 0; +final mode = modeRaw.clamp(0, 3); +_pathHashByteWidth = mode + 1; // 1..4 bytes per hop ``` -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. +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). ### Path Data Structure @@ -56,6 +56,24 @@ 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/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 538ee38a..3da4450d 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -3854,7 +3854,7 @@ class MeshCoreConnector extends ChangeNotifier { } // Path hash mode v10+ (byte 81): width = mode + 1 byte(s) per hop if (frame.length >= 82) { - final mode = (frame[81] & 0xFF).clamp(0, 2); + final mode = (frame[81] & 0xFF).clamp(0, 3); _pathHashByteWidth = mode + 1; } else { _pathHashByteWidth = 1; diff --git a/lib/helpers/path_helper.dart b/lib/helpers/path_helper.dart index ff6448c7..c5cb21f1 100644 --- a/lib/helpers/path_helper.dart +++ b/lib/helpers/path_helper.dart @@ -20,7 +20,7 @@ class PathHelper { static List splitPathBytes(List pathBytes, int hashByteWidth) { if (pathBytes.isEmpty) return const []; - final width = hashByteWidth.clamp(1, 8); + final width = hashByteWidth.clamp(1, 4); final hops = []; for (int i = 0; i < pathBytes.length; i += width) { final endIdx = (i + width).clamp(0, pathBytes.length); @@ -32,6 +32,21 @@ 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/models/channel_message.dart b/lib/models/channel_message.dart index 80c9705b..b29b7d64 100644 --- a/lib/models/channel_message.dart +++ b/lib/models/channel_message.dart @@ -163,12 +163,18 @@ class ChannelMessage { final hasPath = (flags & 0x01) != 0; reader.skipBytes(1); // Skip reserved byte channelIdx = reader.readByte(); - pathLen = reader.readInt8(); - txtType = reader.readByte(); - if (hasPath && pathLen > 0) { - reader.rewind(); // Rewind to read path length again for pathBytes - pathBytes = reader.readBytes(pathLen); + final pathByte = reader.readUInt8(); + // pathByte packs: top 2 bits = hash width mode, low 6 bits = hop count + final packetPathHashWidth = ((pathByte & 0xC0) >> 6) + 1; + final hopCount = pathByte & 0x3F; + pathLen = hopCount; + // If a path is present, read hopCount * width bytes + if (hasPath && hopCount > 0) { + final totalPathBytes = hopCount * packetPathHashWidth; + pathBytes = reader.readBytes(totalPathBytes); } + // After consuming optional path bytes, read the text type byte. + txtType = reader.readByte(); } else { channelIdx = reader.readByte(); pathLen = reader.readInt8(); diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 01d10a9c..a5808ed3 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -119,7 +119,7 @@ class Contact { String pathFormattedIdList(int hashByteWidth) { final pathBytes = pathBytesForDisplay; if (pathBytes.isEmpty) return ''; - final w = hashByteWidth.clamp(1, 8); + final w = hashByteWidth.clamp(1, 4); final parts = []; for (int i = 0; i < pathBytes.length; i += w) { final end = (i + w) <= pathBytes.length ? (i + w) : pathBytes.length; diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index fcd50861..64aedb58 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -36,6 +36,7 @@ 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; @@ -1433,8 +1434,12 @@ class _ChannelChatScreenState extends State { } String _formatPathPrefixes(Uint8List pathBytes) { - return pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + final width = PathHelper.detectPathHashWidth( + pathBytes, + fallback: context.read().pathHashByteWidth, + ); + return PathHelper.splitPathBytes(pathBytes, width) + .map(PathHelper.formatHopHex) .join(','); } } diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 0ed7dd95..cb6a8d23 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 = connector.pathHashByteWidth.clamp(1, 8); + final width = primaryPath.isNotEmpty + ? PathHelper.detectPathHashWidth(primaryPath, fallback: connector.pathHashByteWidth) + : 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: context - .read() - .pathHashByteWidth, + reversePathAround: !(!channelMessage && !message.isOutgoing), + pathHashByteWidth: PathHelper.detectPathHashWidth( + primaryPath, + fallback: context.read().pathHashByteWidth, + ), ), ), ), @@ -491,7 +491,7 @@ class _ChannelMessagePathMapScreenState final selectedIndex = _indexForPath(selectedPath, observedPaths); final hops = _buildPathHops(selectedPath, connector, context.l10n); - final width = connector.pathHashByteWidth.clamp(1, 8); + final width = connector.pathHashByteWidth.clamp(1, 4); final points = []; @@ -951,8 +951,7 @@ List<_PathHop> _buildPathHops( AppLocalizations l10n, ) { if (pathBytes.isEmpty) return const []; - - final width = connector.pathHashByteWidth.clamp(1, 8); + final width = PathHelper.detectPathHashWidth(pathBytes, fallback: connector.pathHashByteWidth); final candidatesByHashBytes = >{}; final allContacts = connector.allContacts; @@ -1067,7 +1066,7 @@ String _formatPathPrefixes(Uint8List pathBytes, int hashByteWidth) { int _hopCountFromBytes(int byteCount, int hashByteWidth) { if (byteCount <= 0) return 0; - final width = hashByteWidth.clamp(1, 8); + final width = hashByteWidth.clamp(1, 4); return (byteCount + width - 1) ~/ width; } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 903e67b2..f6a0c1c5 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1508,6 +1508,7 @@ class _ChatScreenState extends State { title: context.l10n.chat_setCustomPath, currentPathLabel: currentPathLabel, onRefresh: connector.isConnected ? connector.getContacts : null, + pathHashByteWidth: connector.pathHashByteWidth, ); appLogger.info( diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 4ae2fd43..ab5cf804 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -10,8 +10,9 @@ import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; -import '../l10n/l10n.dart'; import '../connector/meshcore_protocol.dart'; +import '../helpers/path_helper.dart'; +import '../l10n/l10n.dart'; import '../models/app_settings.dart'; import '../models/channel.dart'; import '../models/contact.dart'; @@ -268,7 +269,7 @@ class _MapScreenState extends State { ) .join(','); final cacheKey = - '$filteredKeys|$anchorKeys|${pathHistory.version}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}'; + '$filteredKeys|$anchorKeys|${pathHistory.version}:${connector.pathHashByteWidth}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}'; if (cacheKey != _guessedLocationsCacheKey) { _guessedLocationsCacheKey = cacheKey; _cachedGuessedLocations = settings.mapShowGuessedLocations @@ -277,6 +278,7 @@ class _MapScreenState extends State { allContactsWithLocation, pathHistory, maxRangeKm, + connector.pathHashByteWidth, ) : []; } @@ -694,22 +696,8 @@ class _MapScreenState extends State { List withLocation, PathHistoryService pathHistory, double? maxRangeKm, + int pathHashByteWidth, ) { - // Index known-location repeaters by their 1-byte hash. - // null value = two repeaters share the same hash byte (ambiguous collision). - final repeaterByHash = {}; - - for (final c in withLocation) { - if (c.type == advTypeRepeater) { - if (repeaterByHash.containsKey(c.publicKey[0])) { - repeaterByHash[c.publicKey[0]] = - null; // collision: can't disambiguate - } else { - repeaterByHash[c.publicKey[0]] = c; - } - } - } - final result = <_GuessedLocation>[]; for (final contact in allContacts) { @@ -732,13 +720,24 @@ class _MapScreenState extends State { .getRecentPaths(contact.publicKeyHex) .map((r) => r.pathBytes), ]; - final lastHopBytes = {}; for (final pathBytes in pathSets) { if (pathBytes.isEmpty) continue; - final lastHop = pathBytes.last; - lastHopBytes.add(lastHop); - final r = repeaterByHash[lastHop]; - if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!)); + final hopWidth = PathHelper.detectPathHashWidth( + pathBytes, + fallback: pathHashByteWidth, + ); + final lastHop = pathBytes.sublist(max(0, pathBytes.length - hopWidth)); + if (lastHop.isEmpty) continue; + + for (final repeater in withLocation) { + if (repeater.type != advTypeRepeater) continue; + if (repeater.publicKey.length < lastHop.length) continue; + if (!listEquals(repeater.publicKey.sublist(0, lastHop.length), lastHop)) { + continue; + } + anchorSet.add(LatLng(repeater.latitude!, repeater.longitude!)); + break; + } } // Filter anchors that are geometrically inconsistent with radio range. @@ -2290,10 +2289,15 @@ class _MapScreenState extends State { } void _addToPath(BuildContext context, Contact contact, {LatLng? position}) { + final connector = context.read(); + final hopWidth = min( + connector.pathHashByteWidth.clamp(1, pubKeySize), + contact.publicKey.length, + ); setState(() { - _pathTrace.add( - contact.publicKey[0], - ); // Add first 16 bytes of public key to path trace + _pathTrace.addAll( + contact.publicKey.sublist(0, hopWidth), + ); // Add the hop-width prefix of the public key to the trace _pathTraceContacts.add( contact.copyWith( latitude: position?.latitude ?? contact.latitude, diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index 34690a8b..62d5772e 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -114,8 +114,9 @@ class _PathTraceMapScreenState extends State { Contact? _targetContact; String _formatPathPrefixes(Uint8List pathBytes) { - return PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth) - .map(PathHelper.formatHopHex) + final width = PathHelper.detectPathHashWidth(pathBytes, fallback: widget.pathHashByteWidth); + return PathHelper.splitPathBytes(pathBytes, width) + .map(PathHelper.formatHopHex) .join(','); } @@ -214,28 +215,11 @@ class _PathTraceMapScreenState extends State { } final mirroredHops = [...pathHops]; - 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, - ); - } + // Mirror the path without duplicating the pivot hop. + if (pathHops.length > 1) { + mirroredHops.addAll( + pathHops.sublist(0, pathHops.length - 1).reversed, + ); } final traceBytes = []; @@ -343,14 +327,21 @@ class _PathTraceMapScreenState extends State { final buffer = BufferReader(frame); try { buffer.skipBytes(2); // Skip push code and reserved byte - int pathLength = buffer.readUInt8(); - buffer.skipBytes(5); // Skip Flag byte and tag data - buffer.skipBytes(4); // Skip auth code - final pathBytes = buffer.readBytes(pathLength); + // 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; final pathData = PathHelper.splitPathBytes( pathBytes, - widget.pathHashByteWidth, + packetWidth, ); + List snrData = buffer .readRemainingBytes() .map((snr) => snr.toSigned(8).toDouble() / 4) @@ -368,15 +359,25 @@ class _PathTraceMapScreenState extends State { lastSeen: DateTime.now(), ); if (widget.pathContacts != null) { - 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, - }; + 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, + }; + } } 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 && @@ -386,12 +387,17 @@ 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; } @@ -411,7 +417,7 @@ class _PathTraceMapScreenState extends State { (c) => c.hasLocation && c.path.isNotEmpty && - _hopKey(_lastHopChunk(c.path, widget.pathHashByteWidth)) == + _hopKey(_lastHopChunk(c.path, packetWidth)) == hopKey, ) .toList(); @@ -458,9 +464,7 @@ class _PathTraceMapScreenState extends State { (c) => c.hasLocation && c.path.isNotEmpty && - _hopKey( - _lastHopChunk(c.path, widget.pathHashByteWidth), - ) == + _hopKey(_lastHopChunk(c.path, packetWidth)) == lastHopKey, ) .toList(); @@ -846,7 +850,11 @@ class _PathTraceMapScreenState extends State { if (pathTraceData.pathData.isEmpty) { return context.l10n.pathTrace_you; } - if (index == 0 || index == pathTraceData.pathData.length - 1) { + if (index < 0 || index > pathTraceData.pathData.length) { + return context.l10n.pathTrace_you; + } + + if (index == 0 || index == pathTraceData.pathData.length) { if (index == 0) { return context.l10n.pathTrace_you; } else { @@ -871,7 +879,11 @@ class _PathTraceMapScreenState extends State { if (pathTraceData.pathData.isEmpty) { return context.l10n.pathTrace_you; } - if (index == 0 || index == pathTraceData.pathData.length - 1) { + if (index < 0 || index > pathTraceData.pathData.length) { + return context.l10n.pathTrace_you; + } + + if (index == 0 || index == pathTraceData.pathData.length) { if (index == 0) { final hop = pathTraceData.pathData.first; final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name; diff --git a/lib/widgets/path_selection_dialog.dart b/lib/widgets/path_selection_dialog.dart index 5c36279e..949ebbe8 100644 --- a/lib/widgets/path_selection_dialog.dart +++ b/lib/widgets/path_selection_dialog.dart @@ -77,7 +77,7 @@ class _PathSelectionDialogState extends State { } void _updateTextFromContacts() { - final width = widget.pathHashByteWidth.clamp(1, 8); + final width = widget.pathHashByteWidth.clamp(1, 4); final hexCharsPerHop = width * 2; final pathParts = _selectedContacts .map((contact) { @@ -126,7 +126,7 @@ class _PathSelectionDialogState extends State { .toList(); final pathBytesList = []; final invalidPrefixes = []; - final hexCharsPerHop = widget.pathHashByteWidth.clamp(1, 8) * 2; + final hexCharsPerHop = widget.pathHashByteWidth.clamp(1, 4) * 2; for (final id in pathIds) { if (id.length < hexCharsPerHop) { @@ -320,7 +320,7 @@ class _PathSelectionDialogState extends State { style: const TextStyle(fontSize: 14), ), subtitle: Text( - '${contact.typeLabel(l10n)} • ${contact.publicKeyHex.substring(0, widget.pathHashByteWidth.clamp(1, 8) * 2)}', + '${contact.typeLabel(l10n)} • ${contact.publicKeyHex.substring(0, widget.pathHashByteWidth.clamp(1, 4) * 2)}', style: const TextStyle(fontSize: 10), ), trailing: isSelected diff --git a/test/helpers/path_helper_packet_test.dart b/test/helpers/path_helper_packet_test.dart new file mode 100644 index 00000000..a76638c4 --- /dev/null +++ b/test/helpers/path_helper_packet_test.dart @@ -0,0 +1,40 @@ +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]))); + }); +}