diff --git a/docs/BLE_PROTOCOL.md b/docs/BLE_PROTOCOL.md index c17c3e7a..a8edc89a 100644 --- a/docs/BLE_PROTOCOL.md +++ b/docs/BLE_PROTOCOL.md @@ -1287,6 +1287,105 @@ if (reactionInfo != null) { Reactions are parsed, processed to update target message's reaction counts, but never displayed as standalone messages. +## Routing Paths + +MeshCore supports variable-length multi-byte routing paths to scale mesh networks from 256 unique nodes (1-byte hashes) to millions (3-byte hashes). + +### Path Encoding Format + +**Single Byte Encodes Both Hop Count and Hash Mode**: +``` +Path Byte Format: +Bit 7-6: Hash mode (2 bits) → hash_bytes = 1 << mode +Bit 5-0: Hop count (6 bits) → max 63 hops +``` + +**Supported Modes**: + +| Mode | Hash Bytes | Max Hops | Max Unique Nodes | Firmware | +|------|-----------|----------|------------------|----------| +| 0 | 1 | 63 | 256 | v1.13+ | +| 1 | 2 | 63 | 65,536 | v1.14+ | +| 2 | 3 | 63 | 16.7M | v1.14+ | +| 3 | 4 | 63 | 4.3B | v1.14+ | + +**Special Values**: +- Path byte = **255** → Direct or flooded message (no path) +- Path byte = **0** → End of contact path data (padding byte) + +### Path Byte Calculation + +```dart +// Extract mode and hop count from wire byte +final pathLenRaw = frameData[index]; // Single byte +final hopCount = pathLenRaw & 0x3F; // Bits 5-0 +final hashMode = (pathLenRaw >> 6) & 0x03; // Bits 7-6 +final hashBytes = 1 << hashMode; // 1, 2, 4, or 8 + +// Total path bytes = hopCount * hashBytes +final pathByteLength = hopCount * hashBytes; +final pathData = frameData.sublist(index + 1, index + 1 + pathByteLength); +``` + +### Device Capability Detection + +The device firmware version determines multi-byte path support: + +**Firmware Byte 81** (from `RESP_CODE_SELF_INFO` and device initialization): +- Bits 3-0: Path hash mode capability (0-3) +- v1.13 and earlier: Hardcoded mode 0 (1-byte only) +- v1.14+: Firmware reports mode in byte 81; device generates paths in this mode + +**Detection in App**: +```dart +// After receiving RESP_CODE_SELF_INFO +if (firmwareBytes.length >= 82) { + final mode = (firmwareBytes[81] & 0xFF).clamp(0, 2); + pathHashByteWidth = mode + 1; // Sets to 1, 2, 3, or 4 bytes +} else { + pathHashByteWidth = 1; // Fallback for v1.13 +} +``` + +### Path Usage in Different Message Types + +**Direct Messages** (`RESP_CODE_CONTACT_MSG_RECV`/`RESP_CODE_CONTACT_MSG_RECV_V3`): +- Path extracted from payload after decryption +- Used to trace back to sender + +**Channel Messages** (`RESP_CODE_CHANNEL_MSG_RECV`/`RESP_CODE_CHANNEL_MSG_RECV_V3`): +- Group address + encrypted path in payload +- Decrypted to reveal hop-by-hop routing chain + +**Contact Storage** (from `RESP_CODE_CONTACT` frames): +- Byte 32: Path length encoding (hop_count | (mode << 6)) +- Bytes 33-96: Path data (max 64 bytes stored, variable interpretation) + +**Example Contact Path Parsing**: +```dart +// Mode 0 (1-byte): 3 hops = 3 bytes needed +// [0x83, 0xA1, 0xB2, 0xC3] → hopCount=3, mode=0, path=[A1,B2,C3] + +// Mode 1 (2-byte): 3 hops = 6 bytes needed +// [0xC3, 0xA1, 0xA2, 0xB1, 0xB2, 0xC1, 0xC2] +// → hopCount=3, mode=1, path=[A1A2, B1B2, C1C2] +``` + +### Path Validation During Input + +When user enters custom paths in the UI: + +1. **Parse comma-separated hex segments**: `"A1B2,C1C2,D1D2"` +2. **Validate segment length**: Each must be exactly `hashBytes * 2` hex characters +3. **Convert to bytes**: Group hex pairs into single bytes +4. **Check hop count**: Total bytes ÷ hashBytes must be ≤ 63 + +**Example with 2-byte mode**: +- Device reports: `pathHashByteWidth = 2` +- User enters: `"A1B2,C1C2"` (2 hops) +- Parse result: `[0xA1, 0xB2, 0xC1, 0xC2]` (4 bytes total) +- Validation: `4 bytes ÷ 2 bytes/hop = 2 hops` ✓ + ## References - **Firmware Repository**: https://github.com/nonik0/meshcore diff --git a/lib/helpers/path_helper.dart b/lib/helpers/path_helper.dart index fe51d636..5a6d15b5 100644 --- a/lib/helpers/path_helper.dart +++ b/lib/helpers/path_helper.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import '../models/contact.dart'; import '../connector/meshcore_protocol.dart'; @@ -8,24 +9,56 @@ class PathHelper { .join(','); } + /// Resolves path bytes to contact names, supporting multi-byte hash widths. + /// + /// Groups path bytes according to [hashByteWidth]: + /// - 1: Single byte per hop (256 unique nodes) + /// - 2: Two bytes per hop (65K unique nodes) + /// - 3: Three bytes per hop (16M unique nodes) static String resolvePathNames( List pathBytes, List allContacts, + int hashByteWidth, ) { - return pathBytes - .map((b) { - final hex = b.toRadixString(16).padLeft(2, '0').toUpperCase(); - final matches = allContacts - .where( - (c) => - c.publicKey.first == b && - (c.type == advTypeRepeater || c.type == advTypeRoom), - ) - .toList(); - if (matches.isEmpty) return hex; - if (matches.length == 1) return matches.first.name; - return matches.map((c) => c.name).join(' | '); - }) - .join(' \u2192 '); + if (pathBytes.isEmpty) return ''; + + final width = hashByteWidth.clamp(1, 8); + final parts = []; + + // Group bytes according to width + for (int i = 0; i < pathBytes.length; i += width) { + final endIdx = (i + width).clamp(0, pathBytes.length); + final hopBytes = pathBytes.sublist(i, endIdx); + + if (hopBytes.isEmpty) continue; + + // Format hex for display + final hex = hopBytes + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(''); + + // Find matching contacts by comparing public key prefix + final matches = allContacts + .where((c) { + if (c.publicKey.length < hopBytes.length) return false; + if (c.type != advTypeRepeater && c.type != advTypeRoom) return false; + // Compare bytes using listEquals for multi-byte support + return listEquals( + c.publicKey.sublist(0, hopBytes.length), + hopBytes, + ); + }) + .toList(); + + if (matches.isEmpty) { + parts.add(hex); + } else if (matches.length == 1) { + parts.add(matches.first.name); + } else { + parts.add(matches.map((c) => c.name).join(' | ')); + } + } + + return parts.join(' \u2192 '); } } diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index afb2b906..47c1181d 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -42,9 +42,14 @@ class ChannelMessagePathScreen extends StatelessWidget { : primaryPathTmp; final hops = _buildPathHops(primaryPath, connector, l10n); final hasHopDetails = primaryPath.isNotEmpty; + + // Convert path byte length to hop count based on device width + final width = connector.pathHashByteWidth.clamp(1, 8); + final observedHopCount = primaryPath.length ~/ width; + final observedLabel = _formatObservedHops( - primaryPath.length, - message.pathLength, + observedHopCount, + message.pathLength != null ? message.pathLength! ~/ width : null, l10n, ); final extraPaths = _otherPaths(primaryPath, message.pathVariants); @@ -886,6 +891,7 @@ class _PathHop { final Contact? contact; final LatLng? position; final AppLocalizations l10n; + final Uint8List? hopBytes; const _PathHop({ required this.index, @@ -893,12 +899,15 @@ class _PathHop { required this.contact, required this.position, required this.l10n, + this.hopBytes, }); bool get hasLocation => position != null; String get displayLabel { - final prefixLabel = _formatPrefix(prefix); + final prefixLabel = hopBytes != null && hopBytes!.isNotEmpty + ? hopBytes!.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase() + : _formatPrefix(prefix); return '($prefixLabel) ${_resolveName(contact, l10n)}'; } } @@ -916,19 +925,30 @@ List<_PathHop> _buildPathHops( AppLocalizations l10n, ) { if (pathBytes.isEmpty) return const []; - final candidatesByPrefix = >{}; + + final width = connector.pathHashByteWidth.clamp(1, 8); + final candidatesByHashBytes = , List>{}; final allContacts = connector.allContacts; + + // Build lookup map using hash byte sequences for (final contact in allContacts) { if (contact.publicKey.isEmpty) continue; if (contact.type != advTypeRepeater && contact.type != advTypeRoom) { continue; } - final prefix = contact.publicKey.first; - candidatesByPrefix.putIfAbsent(prefix, () => []).add(contact); + // Extract the hash bytes that match the device width + final keyBytes = contact.publicKey.sublist( + 0, + min(width, contact.publicKey.length), + ); + candidatesByHashBytes.putIfAbsent(keyBytes, () => []).add(contact); } - for (final candidates in candidatesByPrefix.values) { + + // Sort candidates by last seen + for (final candidates in candidatesByHashBytes.values) { candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen)); } + final startPoint = (connector.selfLatitude != null && connector.selfLongitude != null) ? LatLng(connector.selfLatitude!, connector.selfLongitude!) @@ -938,10 +958,17 @@ List<_PathHop> _buildPathHops( var lastDistance = 0.0; var bestDistance = 0.0; final hops = <_PathHop>[]; - for (var i = 0; i < pathBytes.length; i++) { - final searchPoint = i == 0 ? startPoint : previousPosition; - final candidates = candidatesByPrefix[pathBytes[i]]; + + // Process path in hop-sized chunks + for (var hopIdx = 0; hopIdx * width < pathBytes.length; hopIdx++) { + final startByte = hopIdx * width; + final endByte = min(startByte + width, pathBytes.length); + final hopBytes = pathBytes.sublist(startByte, endByte); + + final searchPoint = hopIdx == 0 ? startPoint : previousPosition; + final candidates = candidatesByHashBytes[hopBytes.toList()]; Contact? contact; + if (candidates != null && candidates.isNotEmpty) { var bestIndex = 0; if (searchPoint != null) { @@ -965,7 +992,7 @@ List<_PathHop> _buildPathHops( } contact = candidates.removeAt(bestIndex); if (candidates.isEmpty) { - candidatesByPrefix.remove(pathBytes[i]); + candidatesByHashBytes.remove(hopBytes.toList()); } } @@ -973,11 +1000,12 @@ List<_PathHop> _buildPathHops( if (resolvedPosition != null) { previousPosition = resolvedPosition; } + // If the best candidate is much farther than the previous hop, it's likely not the correct match. if (lastDistance + bestDistance > 50000 && candidates != null && candidates.isNotEmpty) { - i--; + hopIdx--; lastDistance = bestDistance; continue; } @@ -985,11 +1013,12 @@ List<_PathHop> _buildPathHops( hops.add( _PathHop( - index: i + 1, - prefix: pathBytes[i], + index: hopIdx + 1, + prefix: hopBytes.isNotEmpty ? hopBytes[0] : 0, contact: contact, position: resolvedPosition, l10n: l10n, + hopBytes: hopBytes, ), ); } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 8d3bc66c..903e67b2 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1111,7 +1111,11 @@ class _ChatScreenState extends State { final allContacts = connector.allContacts; final formattedPath = PathHelper.formatPathHex(pathBytes); - final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts); + final resolvedNames = PathHelper.resolvePathNames( + pathBytes, + allContacts, + connector.pathHashByteWidth, + ); showDialog( context: context, diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index a2122f46..aa877369 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -79,7 +79,11 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { final allContacts = connector.allContacts; final formattedPath = PathHelper.formatPathHex(pathBytes); - final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts); + final resolvedNames = PathHelper.resolvePathNames( + pathBytes, + allContacts, + connector.pathHashByteWidth, + ); showDialog( context: context, @@ -150,6 +154,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> { initialPath: pathForInput.isEmpty ? null : pathForInput, currentPathLabel: currentContact.pathLabel(l10n), onRefresh: connector.isConnected ? connector.getContacts : null, + pathHashByteWidth: connector.pathHashByteWidth, ); if (result != null && context.mounted) { diff --git a/lib/widgets/path_selection_dialog.dart b/lib/widgets/path_selection_dialog.dart index 44ae58f9..70ba7601 100644 --- a/lib/widgets/path_selection_dialog.dart +++ b/lib/widgets/path_selection_dialog.dart @@ -12,6 +12,7 @@ class PathSelectionDialog extends StatefulWidget { final String? initialPath; final String? currentPathLabel; final VoidCallback? onRefresh; + final int pathHashByteWidth; const PathSelectionDialog({ super.key, @@ -20,6 +21,7 @@ class PathSelectionDialog extends StatefulWidget { this.initialPath, this.currentPathLabel, this.onRefresh, + this.pathHashByteWidth = 1, }); @override @@ -32,6 +34,7 @@ class PathSelectionDialog extends StatefulWidget { String? initialPath, String? currentPathLabel, VoidCallback? onRefresh, + int pathHashByteWidth = 1, }) { return showDialog( context: context, @@ -41,6 +44,7 @@ class PathSelectionDialog extends StatefulWidget { initialPath: initialPath, currentPathLabel: currentPathLabel, onRefresh: onRefresh, + pathHashByteWidth: pathHashByteWidth, ), ); } @@ -73,10 +77,12 @@ class _PathSelectionDialogState extends State { } void _updateTextFromContacts() { + final width = widget.pathHashByteWidth.clamp(1, 8); + final hexCharsPerHop = width * 2; final pathParts = _selectedContacts .map((contact) { - if (contact.publicKeyHex.length >= 2) { - return contact.publicKeyHex.substring(0, 2); + if (contact.publicKeyHex.length >= hexCharsPerHop) { + return contact.publicKeyHex.substring(0, hexCharsPerHop); } return ''; }) @@ -112,6 +118,9 @@ class _PathSelectionDialogState extends State { return; } + final width = widget.pathHashByteWidth.clamp(1, 8); + final expectedHexLen = width * 2; // 2 hex chars per byte + // Parse comma-separated hex prefixes final pathIds = path .split(',') diff --git a/test/helpers/path_helper_test.dart b/test/helpers/path_helper_test.dart index 38abf2cd..e2da8776 100644 --- a/test/helpers/path_helper_test.dart +++ b/test/helpers/path_helper_test.dart @@ -22,15 +22,52 @@ Contact _contact({ } void main() { - test('resolvePathNames ignores chat nodes and keeps repeater/room nodes', () { + test('resolvePathNames ignores chat nodes and keeps repeater/room nodes with 1-byte paths', () { final contacts = [ _contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat), _contact(firstByte: 0x7E, name: 'zrepeater', type: advTypeRepeater), _contact(firstByte: 0xBA, name: 'USS Ronald Reagan', type: advTypeRoom), ]; - final resolved = PathHelper.resolvePathNames([0xF2, 0x7E, 0xBA], contacts); + final resolved = PathHelper.resolvePathNames( + [0xF2, 0x7E, 0xBA], + contacts, + 1, // 1-byte hash width + ); expect(resolved, equals('F2 → zrepeater → USS Ronald Reagan')); }); + + test('resolvePathNames supports multi-byte paths', () { + final contacts = [ + _contact(firstByte: 0xA1, name: 'Repeater1', type: advTypeRepeater), + _contact(firstByte: 0xC1, name: 'Room42', type: advTypeRoom), + ]; + + // Contacts with 2-byte public keys + contacts[0] = Contact( + publicKey: Uint8List.fromList([0xA1, 0xA2, ...List.filled(30, 0)]), + name: 'Repeater1', + type: advTypeRepeater, + pathLength: 0, + path: Uint8List(0), + lastSeen: DateTime.now(), + ); + contacts[1] = Contact( + publicKey: Uint8List.fromList([0xC1, 0xC2, ...List.filled(30, 0)]), + name: 'Room42', + type: advTypeRoom, + pathLength: 0, + path: Uint8List(0), + lastSeen: DateTime.now(), + ); + + final resolved = PathHelper.resolvePathNames( + [0xA1, 0xA2, 0xC1, 0xC2], + contacts, + 2, // 2-byte hash width + ); + + expect(resolved, equals('Repeater1 → Room42')); + }); }