multi-byte paths

This commit is contained in:
PacoX
2026-05-12 16:08:19 +02:00
parent 21c58d4e13
commit 17379394bd
7 changed files with 251 additions and 35 deletions
+99
View File
@@ -1287,6 +1287,105 @@ if (reactionInfo != null) {
Reactions are parsed, processed to update target message's reaction counts, but never displayed as standalone messages. 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 ## References
- **Firmware Repository**: https://github.com/nonik0/meshcore - **Firmware Repository**: https://github.com/nonik0/meshcore
+48 -15
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
@@ -8,24 +9,56 @@ class PathHelper {
.join(','); .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( static String resolvePathNames(
List<int> pathBytes, List<int> pathBytes,
List<Contact> allContacts, List<Contact> allContacts,
int hashByteWidth,
) { ) {
return pathBytes if (pathBytes.isEmpty) return '';
.map((b) {
final hex = b.toRadixString(16).padLeft(2, '0').toUpperCase(); final width = hashByteWidth.clamp(1, 8);
final matches = allContacts final parts = <String>[];
.where(
(c) => // Group bytes according to width
c.publicKey.first == b && for (int i = 0; i < pathBytes.length; i += width) {
(c.type == advTypeRepeater || c.type == advTypeRoom), final endIdx = (i + width).clamp(0, pathBytes.length);
) final hopBytes = pathBytes.sublist(i, endIdx);
.toList();
if (matches.isEmpty) return hex; if (hopBytes.isEmpty) continue;
if (matches.length == 1) return matches.first.name;
return matches.map((c) => c.name).join(' | '); // Format hex for display
}) final hex = hopBytes
.join(' \u2192 '); .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 ');
} }
} }
+43 -14
View File
@@ -42,9 +42,14 @@ class ChannelMessagePathScreen extends StatelessWidget {
: primaryPathTmp; : primaryPathTmp;
final hops = _buildPathHops(primaryPath, connector, l10n); final hops = _buildPathHops(primaryPath, connector, l10n);
final hasHopDetails = primaryPath.isNotEmpty; 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( final observedLabel = _formatObservedHops(
primaryPath.length, observedHopCount,
message.pathLength, message.pathLength != null ? message.pathLength! ~/ width : null,
l10n, l10n,
); );
final extraPaths = _otherPaths(primaryPath, message.pathVariants); final extraPaths = _otherPaths(primaryPath, message.pathVariants);
@@ -886,6 +891,7 @@ class _PathHop {
final Contact? contact; final Contact? contact;
final LatLng? position; final LatLng? position;
final AppLocalizations l10n; final AppLocalizations l10n;
final Uint8List? hopBytes;
const _PathHop({ const _PathHop({
required this.index, required this.index,
@@ -893,12 +899,15 @@ class _PathHop {
required this.contact, required this.contact,
required this.position, required this.position,
required this.l10n, required this.l10n,
this.hopBytes,
}); });
bool get hasLocation => position != null; bool get hasLocation => position != null;
String get displayLabel { 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)}'; return '($prefixLabel) ${_resolveName(contact, l10n)}';
} }
} }
@@ -916,19 +925,30 @@ List<_PathHop> _buildPathHops(
AppLocalizations l10n, AppLocalizations l10n,
) { ) {
if (pathBytes.isEmpty) return const []; if (pathBytes.isEmpty) return const [];
final candidatesByPrefix = <int, List<Contact>>{};
final width = connector.pathHashByteWidth.clamp(1, 8);
final candidatesByHashBytes = <List<int>, List<Contact>>{};
final allContacts = connector.allContacts; final allContacts = connector.allContacts;
// Build lookup map using hash byte sequences
for (final contact in allContacts) { for (final contact in allContacts) {
if (contact.publicKey.isEmpty) continue; if (contact.publicKey.isEmpty) continue;
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) { if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
continue; continue;
} }
final prefix = contact.publicKey.first; // Extract the hash bytes that match the device width
candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact); final keyBytes = contact.publicKey.sublist(
0,
min(width, contact.publicKey.length),
);
candidatesByHashBytes.putIfAbsent(keyBytes, () => <Contact>[]).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)); candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
} }
final startPoint = final startPoint =
(connector.selfLatitude != null && connector.selfLongitude != null) (connector.selfLatitude != null && connector.selfLongitude != null)
? LatLng(connector.selfLatitude!, connector.selfLongitude!) ? LatLng(connector.selfLatitude!, connector.selfLongitude!)
@@ -938,10 +958,17 @@ List<_PathHop> _buildPathHops(
var lastDistance = 0.0; var lastDistance = 0.0;
var bestDistance = 0.0; var bestDistance = 0.0;
final hops = <_PathHop>[]; final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) {
final searchPoint = i == 0 ? startPoint : previousPosition; // Process path in hop-sized chunks
final candidates = candidatesByPrefix[pathBytes[i]]; 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; Contact? contact;
if (candidates != null && candidates.isNotEmpty) { if (candidates != null && candidates.isNotEmpty) {
var bestIndex = 0; var bestIndex = 0;
if (searchPoint != null) { if (searchPoint != null) {
@@ -965,7 +992,7 @@ List<_PathHop> _buildPathHops(
} }
contact = candidates.removeAt(bestIndex); contact = candidates.removeAt(bestIndex);
if (candidates.isEmpty) { if (candidates.isEmpty) {
candidatesByPrefix.remove(pathBytes[i]); candidatesByHashBytes.remove(hopBytes.toList());
} }
} }
@@ -973,11 +1000,12 @@ List<_PathHop> _buildPathHops(
if (resolvedPosition != null) { if (resolvedPosition != null) {
previousPosition = resolvedPosition; previousPosition = resolvedPosition;
} }
// If the best candidate is much farther than the previous hop, it's likely not the correct match. // If the best candidate is much farther than the previous hop, it's likely not the correct match.
if (lastDistance + bestDistance > 50000 && if (lastDistance + bestDistance > 50000 &&
candidates != null && candidates != null &&
candidates.isNotEmpty) { candidates.isNotEmpty) {
i--; hopIdx--;
lastDistance = bestDistance; lastDistance = bestDistance;
continue; continue;
} }
@@ -985,11 +1013,12 @@ List<_PathHop> _buildPathHops(
hops.add( hops.add(
_PathHop( _PathHop(
index: i + 1, index: hopIdx + 1,
prefix: pathBytes[i], prefix: hopBytes.isNotEmpty ? hopBytes[0] : 0,
contact: contact, contact: contact,
position: resolvedPosition, position: resolvedPosition,
l10n: l10n, l10n: l10n,
hopBytes: hopBytes,
), ),
); );
} }
+5 -1
View File
@@ -1111,7 +1111,11 @@ class _ChatScreenState extends State<ChatScreen> {
final allContacts = connector.allContacts; final allContacts = connector.allContacts;
final formattedPath = PathHelper.formatPathHex(pathBytes); final formattedPath = PathHelper.formatPathHex(pathBytes);
final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts); final resolvedNames = PathHelper.resolvePathNames(
pathBytes,
allContacts,
connector.pathHashByteWidth,
);
showDialog( showDialog(
context: context, context: context,
+6 -1
View File
@@ -79,7 +79,11 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
final allContacts = connector.allContacts; final allContacts = connector.allContacts;
final formattedPath = PathHelper.formatPathHex(pathBytes); final formattedPath = PathHelper.formatPathHex(pathBytes);
final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts); final resolvedNames = PathHelper.resolvePathNames(
pathBytes,
allContacts,
connector.pathHashByteWidth,
);
showDialog( showDialog(
context: context, context: context,
@@ -150,6 +154,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
initialPath: pathForInput.isEmpty ? null : pathForInput, initialPath: pathForInput.isEmpty ? null : pathForInput,
currentPathLabel: currentContact.pathLabel(l10n), currentPathLabel: currentContact.pathLabel(l10n),
onRefresh: connector.isConnected ? connector.getContacts : null, onRefresh: connector.isConnected ? connector.getContacts : null,
pathHashByteWidth: connector.pathHashByteWidth,
); );
if (result != null && context.mounted) { if (result != null && context.mounted) {
+11 -2
View File
@@ -12,6 +12,7 @@ class PathSelectionDialog extends StatefulWidget {
final String? initialPath; final String? initialPath;
final String? currentPathLabel; final String? currentPathLabel;
final VoidCallback? onRefresh; final VoidCallback? onRefresh;
final int pathHashByteWidth;
const PathSelectionDialog({ const PathSelectionDialog({
super.key, super.key,
@@ -20,6 +21,7 @@ class PathSelectionDialog extends StatefulWidget {
this.initialPath, this.initialPath,
this.currentPathLabel, this.currentPathLabel,
this.onRefresh, this.onRefresh,
this.pathHashByteWidth = 1,
}); });
@override @override
@@ -32,6 +34,7 @@ class PathSelectionDialog extends StatefulWidget {
String? initialPath, String? initialPath,
String? currentPathLabel, String? currentPathLabel,
VoidCallback? onRefresh, VoidCallback? onRefresh,
int pathHashByteWidth = 1,
}) { }) {
return showDialog<Uint8List?>( return showDialog<Uint8List?>(
context: context, context: context,
@@ -41,6 +44,7 @@ class PathSelectionDialog extends StatefulWidget {
initialPath: initialPath, initialPath: initialPath,
currentPathLabel: currentPathLabel, currentPathLabel: currentPathLabel,
onRefresh: onRefresh, onRefresh: onRefresh,
pathHashByteWidth: pathHashByteWidth,
), ),
); );
} }
@@ -73,10 +77,12 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
} }
void _updateTextFromContacts() { void _updateTextFromContacts() {
final width = widget.pathHashByteWidth.clamp(1, 8);
final hexCharsPerHop = width * 2;
final pathParts = _selectedContacts final pathParts = _selectedContacts
.map((contact) { .map((contact) {
if (contact.publicKeyHex.length >= 2) { if (contact.publicKeyHex.length >= hexCharsPerHop) {
return contact.publicKeyHex.substring(0, 2); return contact.publicKeyHex.substring(0, hexCharsPerHop);
} }
return ''; return '';
}) })
@@ -112,6 +118,9 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
return; return;
} }
final width = widget.pathHashByteWidth.clamp(1, 8);
final expectedHexLen = width * 2; // 2 hex chars per byte
// Parse comma-separated hex prefixes // Parse comma-separated hex prefixes
final pathIds = path final pathIds = path
.split(',') .split(',')
+39 -2
View File
@@ -22,15 +22,52 @@ Contact _contact({
} }
void main() { 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 = [ final contacts = [
_contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat), _contact(firstByte: 0xF2, name: 'MunTui', type: advTypeChat),
_contact(firstByte: 0x7E, name: 'zrepeater', type: advTypeRepeater), _contact(firstByte: 0x7E, name: 'zrepeater', type: advTypeRepeater),
_contact(firstByte: 0xBA, name: 'USS Ronald Reagan', type: advTypeRoom), _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')); 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'));
});
} }