diff --git a/PR_MULTIBYTE_PATH.md b/PR_MULTIBYTE_PATH.md new file mode 100644 index 00000000..423ca7ce --- /dev/null +++ b/PR_MULTIBYTE_PATH.md @@ -0,0 +1,130 @@ +# Add Support for Multi-Byte Routing Paths + +## Summary + +Adds support for variable-length multi-byte routing paths in MeshCore Open, enabling the app to scale from small meshes (256 unique nodes, 1-byte hashes) to very large networks (65K+ nodes with 2-byte hashes, 16M+ with 3-byte hashes). + +The implementation detects device capability via byte 81 in `RESP_CODE_SELF_INFO` (firmware v1.10+). This approach is similar to how meshcore-py handles multi-byte paths, though meshcore-py reads the capability from `DEVICE_INFO` instead. + +## Behavior Changes + +- **Device capability detection**: App now reads byte 81 from `RESP_CODE_SELF_INFO` to detect supported path hash width (1, 2, 3, or 4 bytes) +- **Path resolution**: Contact names now resolved correctly for multi-byte paths via `PathHelper.resolvePathNames(hashByteWidth)` +- **Path entry UI**: Chat path dialog and custom path entry now support comma-separated hex prefixes matching device capability (e.g., `A1B2,C1C2` for 2-byte mode) +- **Backward compatible**: Defaults to 1-byte mode (v1.13 and earlier firmware) when capability byte unavailable + +## BLE Protocol Changes (Explicit) + +**Path Encoding** (firmware byte 81, device capability): +- Single byte packs hop count (bits 5-0) + hash mode (bits 7-6) +- Hash modes: 0=1-byte, 1=2-byte, 2=3-byte, 3=4-byte +- Supported by firmware v1.10+ (v1.13 and earlier hardcoded to mode 0) +- No wire format changes; purely capability detection + +**Frame Parsing** (unchanged): +- `RESP_CODE_CONTACT` byte 32 still contains path length (hop_count | mode << 6) +- Bytes 33-96 contain path data (interpretation varies by hash width) +- No impact on existing 1-byte deployments + +## Changes + +### Protocol & Encoding +- **Path encoding**: Single byte packs both hop count (bits 5-0) and hash mode (bits 7-6) +- **Hash modes**: 0 (1-byte), 1 (2-byte), 2 (3-byte), 3 (4-byte) +- **Total path bytes** = hopCount × hashByteWidth +- **Firmware detection**: Device reports capability via byte 81 in `RESP_CODE_SELF_INFO` (v1.10+) + +### Code Changes + +#### 1. **Connector (`meshcore_connector.dart`)** +- Added `_pathHashByteWidth` field, initialized to 1 (default mode) +- Added `pathHashByteWidth` getter for UI and frame builders +- Device capability detection on `RESP_CODE_SELF_INFO`: + ```dart + if (frame.length >= 82) { + final mode = (frame[81] & 0xFF).clamp(0, 2); + _pathHashByteWidth = mode + 1; + } else { + _pathHashByteWidth = 1; // Fallback for v1.13 and earlier firmware + } + ``` + +#### 2. **Path Helper (`path_helper.dart`)** +- Updated `resolvePathNames()` to accept `hashByteWidth` parameter +- Groups path bytes according to width: `for (int i = 0; i < pathBytes.length; i += width)` +- Matches contact public key prefixes using `listEquals()` for multi-byte comparison +- Returns formatted path string with node names or hex fallback + +#### 3. **Path Selection Dialog (`path_selection_dialog.dart`)** +- Removed unused variables that triggered analyzer warnings +- Dialog now supports multibyte path entry via `pathHashByteWidth` parameter +- UI hints user about expected format based on device capability + +#### 4. **UI Updates** +- Chat screen now displays: "Custom path entry follows the device's current hash width" +- Path selection dialog accepts comma-separated hex prefixes (e.g., `A1B2,C1C2` for 2-byte mode) +- Backward compatible: existing 1-byte paths work unchanged + +### Testing +- Added comprehensive test suite in `path_helper_test.dart`: + - 1-byte mode with repeater/room node name resolution + - 2-byte mode with multi-byte prefix matching + - Fallback hex display when contacts not found + - All tests passing (229 total, 0 failures) + +### Documentation +- New dedicated page: `documentation/routing-paths.md` + - Path encoding format and byte calculation + - Device capability detection flow + - Path usage in different message types + - Contact path storage and parsing examples +- Updated `documentation/README.md` to link routing paths documentation +- Updated `documentation/chat-and-messaging.md` with UI path entry notes +- Updated `docs/BLE_PROTOCOL.md` with reference pointer + +### Quality +- `flutter analyze`: ✅ No issues +- `flutter test`: ✅ 229 tests passing +- Backward compatible: ✅ Defaults to 1-byte mode (v1.13 support) +- No breaking changes to existing APIs + +## Screenshots & UI Testing + +### Path Selection Dialog (Multi-Byte Mode) +- **Before**: Only accepts single hex bytes (e.g., `A1,B2,C3`) +- **After**: Now accepts hex prefixes matching device capability (e.g., `A1B2,C1C2` for 2-byte; `A1B2C1,D1D2D3` for 3-byte) +- **Behavior**: Dialog automatically adjusts validation based on `pathHashByteWidth` from connected device +- **Fallback**: Invalid entries show snackbar with error message (unchanged) + +### Chat Screen Path Display +- **Before**: "Custom path" label +- **After**: Includes hint "Custom path entry follows the device's current hash width" +- **Visual**: No appearance changes; purely informational text + +### Path History / Path Management +- Shows resolved contact names for all path widths +- Hex fallback (e.g., "A1B2 → C1C2") when contacts not in list +- Works seamlessly across 1-byte and multi-byte networks + +## Linked Issues + + +- Relates to: Multi-byte path support in MeshCore firmware v1.10+ +- Supersedes: Any previous path scaling limitations +- Reference: meshcore-py handles multi-byte paths via same firmware capability + +## Related PRs & References + +- **Reference implementation**: [meshcore-py](https://github.com/nonik0/meshcore-py) also handles multi-byte paths using same firmware capability +- **Firmware support**: MeshCore v1.10+ reports path hash mode in byte 81 of `RESP_CODE_SELF_INFO` (v1.13 and earlier hardcoded to 1-byte) +- **Protocol reference**: `docs/BLE_PROTOCOL.md` and `documentation/routing-paths.md` +- **MeshCore companion radio**: Implements path mode selection via `set path.hash.mode` CLI command + +## Migration & Deployment Notes + +- **No action required for users**: App automatically detects device capability +- **Existing 1-byte deployments**: Continue to work unchanged +- **New multi-byte devices**: Automatically detected and supported +- **Mixed networks**: 1-byte and multi-byte nodes coexist seamlessly +- **Firmware v1.13 and earlier**: Fallback to 1-byte mode (no change to behavior) +- **Firmware v1.10+**: Multi-byte paths enabled automatically on first connection diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 328acf31..df20f2b7 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -41,20 +41,23 @@ class ChannelMessagePathScreen extends StatelessWidget { final primaryPath = !channelMessage && !message.isOutgoing ? Uint8List.fromList(primaryPathTmp.reversed.toList()) : primaryPathTmp; - final hops = _buildPathHops(primaryPath, connector, l10n); + final hashByteWidth = (message.pathHashWidth ?? connector.pathHashByteWidth) + .clamp(1, 4) + .toInt(); + final hops = _buildPathHops(primaryPath, connector, l10n, hashByteWidth); final hasHopDetails = primaryPath.isNotEmpty; - // Convert path byte length to hop count based on device width - final width = connector.pathHashByteWidth.clamp(1, 4); - final observedHopCount = _hopCountFromBytes(primaryPath.length, width); + // Convert path byte length to hop count using the packet width. + // Legacy messages fall back to the current connector width. + final observedHopCount = _hopCountFromBytes(primaryPath.length, hashByteWidth); final reportedHopCount = message.pathLength == null - ? null - : (message.pathLength! < 0 - ? message.pathLength - : _hopCountFromBytes(message.pathLength!, width)); + ? null + : (message.pathLength! < 0 + ? message.pathLength + : _hopCountFromBytes(message.pathLength!, hashByteWidth)); final effectiveHopCount = observedHopCount > 0 - ? observedHopCount - : reportedHopCount; + ? observedHopCount + : reportedHopCount; final observedLabel = _formatObservedHops( observedHopCount, @@ -78,9 +81,7 @@ class ChannelMessagePathScreen extends StatelessWidget { flipPathAround: true, reversePathAround: !(!channelMessage && !message.isOutgoing), - pathHashByteWidth: context - .read() - .pathHashByteWidth, + pathHashByteWidth: hashByteWidth, ), ), ), @@ -113,7 +114,7 @@ class ChannelMessagePathScreen extends StatelessWidget { style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), - _buildPathVariants(context, extraPaths, width), + _buildPathVariants(context, extraPaths, hashByteWidth), const SizedBox(height: 16), ], Text( @@ -227,7 +228,7 @@ class ChannelMessagePathScreen extends StatelessWidget { subtitle: Text( hop.hasLocation ? '${hop.position!.latitude.toStringAsFixed(5)}, ' - '${hop.position!.longitude.toStringAsFixed(5)}' + '${hop.position!.longitude.toStringAsFixed(5)}' : l10n.channelPath_noLocationData, ), ), @@ -490,8 +491,10 @@ class _ChannelMessagePathMapScreenState : selectedPathTmp; final selectedIndex = _indexForPath(selectedPath, observedPaths); - final hops = _buildPathHops(selectedPath, connector, context.l10n); - final width = connector.pathHashByteWidth.clamp(1, 4); + final width = (widget.message.pathHashWidth ?? connector.pathHashByteWidth) + .clamp(1, 4) + .toInt(); + final hops = _buildPathHops(selectedPath, connector, context.l10n, width); final points = []; @@ -647,7 +650,7 @@ class _ChannelMessagePathMapScreenState final l10n = context.l10n; final width = context.read().pathHashByteWidth.clamp( 1, - 8, + 4, ); final selectedPath = paths[selectedIndex]; final label = selectedPath.isPrimary @@ -949,10 +952,11 @@ List<_PathHop> _buildPathHops( Uint8List pathBytes, MeshCoreConnector connector, AppLocalizations l10n, + int hashByteWidth, ) { if (pathBytes.isEmpty) return const []; - final width = connector.pathHashByteWidth.clamp(1, 4); + final width = hashByteWidth.clamp(1, 4); final candidatesByHashBytes = >{}; final allContacts = connector.allContacts; diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 2a5a3db7..f9581f91 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; +import 'package:meshcore_open/helpers/path_helper.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:provider/provider.dart'; @@ -264,7 +265,7 @@ class _MapScreenState extends State { final anchorKeys = allContactsWithLocation .map( (c) => - '${c.publicKeyHex}:${c.latitude}:${c.longitude}:${c.path.isNotEmpty ? c.path.last : ""}', + '${c.publicKeyHex}:${c.latitude}:${c.longitude}:${PathHelper.formatHopHex(c.path.isNotEmpty ? c.path.sublist(max(0, c.path.length - connector.pathHashByteWidth.clamp(1, 4))) : const [])}', ) .join(','); final cacheKey = @@ -711,8 +712,7 @@ class _MapScreenState extends State { // Collect the contact-side (last-hop) repeater from every known path. // path = [device-side hop, ..., contact-side hop] - // Only path.last is actually within radio range of the contact — using - // earlier bytes would anchor against our own side of the network. + // Only the last hop chunk is actually within radio range of the contact. final pathSets = >[ contact.path.toList(), ...pathHistory @@ -2398,6 +2398,9 @@ class _MapScreenState extends State { title: l10n.contacts_pathTrace, path: Uint8List.fromList(_pathTrace), flipPathAround: true, + pathHashByteWidth: context + .read() + .pathHashByteWidth, ), ), );