mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-11 19:17:04 +10:00
path aware fix
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
<!-- Link to any related issues or PRs here -->
|
||||||
|
- 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
|
||||||
@@ -41,20 +41,23 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
final primaryPath = !channelMessage && !message.isOutgoing
|
final primaryPath = !channelMessage && !message.isOutgoing
|
||||||
? Uint8List.fromList(primaryPathTmp.reversed.toList())
|
? Uint8List.fromList(primaryPathTmp.reversed.toList())
|
||||||
: primaryPathTmp;
|
: 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;
|
final hasHopDetails = primaryPath.isNotEmpty;
|
||||||
|
|
||||||
// Convert path byte length to hop count based on device width
|
// Convert path byte length to hop count using the packet width.
|
||||||
final width = connector.pathHashByteWidth.clamp(1, 4);
|
// Legacy messages fall back to the current connector width.
|
||||||
final observedHopCount = _hopCountFromBytes(primaryPath.length, width);
|
final observedHopCount = _hopCountFromBytes(primaryPath.length, hashByteWidth);
|
||||||
final reportedHopCount = message.pathLength == null
|
final reportedHopCount = message.pathLength == null
|
||||||
? null
|
? null
|
||||||
: (message.pathLength! < 0
|
: (message.pathLength! < 0
|
||||||
? message.pathLength
|
? message.pathLength
|
||||||
: _hopCountFromBytes(message.pathLength!, width));
|
: _hopCountFromBytes(message.pathLength!, hashByteWidth));
|
||||||
final effectiveHopCount = observedHopCount > 0
|
final effectiveHopCount = observedHopCount > 0
|
||||||
? observedHopCount
|
? observedHopCount
|
||||||
: reportedHopCount;
|
: reportedHopCount;
|
||||||
|
|
||||||
final observedLabel = _formatObservedHops(
|
final observedLabel = _formatObservedHops(
|
||||||
observedHopCount,
|
observedHopCount,
|
||||||
@@ -78,9 +81,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
flipPathAround: true,
|
flipPathAround: true,
|
||||||
reversePathAround:
|
reversePathAround:
|
||||||
!(!channelMessage && !message.isOutgoing),
|
!(!channelMessage && !message.isOutgoing),
|
||||||
pathHashByteWidth: context
|
pathHashByteWidth: hashByteWidth,
|
||||||
.read<MeshCoreConnector>()
|
|
||||||
.pathHashByteWidth,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -113,7 +114,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
style: Theme.of(context).textTheme.titleSmall,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildPathVariants(context, extraPaths, width),
|
_buildPathVariants(context, extraPaths, hashByteWidth),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
Text(
|
Text(
|
||||||
@@ -227,7 +228,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
hop.hasLocation
|
hop.hasLocation
|
||||||
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
||||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||||
: l10n.channelPath_noLocationData,
|
: l10n.channelPath_noLocationData,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -490,8 +491,10 @@ class _ChannelMessagePathMapScreenState
|
|||||||
: selectedPathTmp;
|
: selectedPathTmp;
|
||||||
|
|
||||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||||
final hops = _buildPathHops(selectedPath, connector, context.l10n);
|
final width = (widget.message.pathHashWidth ?? connector.pathHashByteWidth)
|
||||||
final width = connector.pathHashByteWidth.clamp(1, 4);
|
.clamp(1, 4)
|
||||||
|
.toInt();
|
||||||
|
final hops = _buildPathHops(selectedPath, connector, context.l10n, width);
|
||||||
|
|
||||||
final points = <LatLng>[];
|
final points = <LatLng>[];
|
||||||
|
|
||||||
@@ -647,7 +650,7 @@ class _ChannelMessagePathMapScreenState
|
|||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final width = context.read<MeshCoreConnector>().pathHashByteWidth.clamp(
|
final width = context.read<MeshCoreConnector>().pathHashByteWidth.clamp(
|
||||||
1,
|
1,
|
||||||
8,
|
4,
|
||||||
);
|
);
|
||||||
final selectedPath = paths[selectedIndex];
|
final selectedPath = paths[selectedIndex];
|
||||||
final label = selectedPath.isPrimary
|
final label = selectedPath.isPrimary
|
||||||
@@ -949,10 +952,11 @@ List<_PathHop> _buildPathHops(
|
|||||||
Uint8List pathBytes,
|
Uint8List pathBytes,
|
||||||
MeshCoreConnector connector,
|
MeshCoreConnector connector,
|
||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
|
int hashByteWidth,
|
||||||
) {
|
) {
|
||||||
if (pathBytes.isEmpty) return const [];
|
if (pathBytes.isEmpty) return const [];
|
||||||
|
|
||||||
final width = connector.pathHashByteWidth.clamp(1, 4);
|
final width = hashByteWidth.clamp(1, 4);
|
||||||
final candidatesByHashBytes = <String, List<Contact>>{};
|
final candidatesByHashBytes = <String, List<Contact>>{};
|
||||||
final allContacts = connector.allContacts;
|
final allContacts = connector.allContacts;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.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/screens/path_trace_map.dart';
|
||||||
import 'package:meshcore_open/widgets/app_bar.dart';
|
import 'package:meshcore_open/widgets/app_bar.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -264,7 +265,7 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
final anchorKeys = allContactsWithLocation
|
final anchorKeys = allContactsWithLocation
|
||||||
.map(
|
.map(
|
||||||
(c) =>
|
(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(',');
|
.join(',');
|
||||||
final cacheKey =
|
final cacheKey =
|
||||||
@@ -711,8 +712,7 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
|
|
||||||
// Collect the contact-side (last-hop) repeater from every known path.
|
// Collect the contact-side (last-hop) repeater from every known path.
|
||||||
// path = [device-side hop, ..., contact-side hop]
|
// path = [device-side hop, ..., contact-side hop]
|
||||||
// Only path.last is actually within radio range of the contact — using
|
// Only the last hop chunk is actually within radio range of the contact.
|
||||||
// earlier bytes would anchor against our own side of the network.
|
|
||||||
final pathSets = <List<int>>[
|
final pathSets = <List<int>>[
|
||||||
contact.path.toList(),
|
contact.path.toList(),
|
||||||
...pathHistory
|
...pathHistory
|
||||||
@@ -2398,6 +2398,9 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
title: l10n.contacts_pathTrace,
|
title: l10n.contacts_pathTrace,
|
||||||
path: Uint8List.fromList(_pathTrace),
|
path: Uint8List.fromList(_pathTrace),
|
||||||
flipPathAround: true,
|
flipPathAround: true,
|
||||||
|
pathHashByteWidth: context
|
||||||
|
.read<MeshCoreConnector>()
|
||||||
|
.pathHashByteWidth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user