mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-29 23:08:44 +10:00
PR review modif + packet aware
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -18,18 +18,18 @@ The device capability determines the hash width (number of bytes per hop):
|
|||||||
|
|
||||||
### Device Capability Detection
|
### 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
|
```dart
|
||||||
// Read from device info response (offset 81)
|
// Read from device info response (offset 81)
|
||||||
final modeRaw = firmwareBytes.length >= 82
|
final modeRaw = firmwareBytes.length >= 82
|
||||||
? (firmwareBytes[81] & 0xFF)
|
? (firmwareBytes[81] & 0xFF)
|
||||||
: 0;
|
: 0;
|
||||||
final mode = modeRaw.clamp(0, 2);
|
final mode = modeRaw.clamp(0, 3);
|
||||||
_pathHashByteWidth = mode + 1; // 1, 2, or 3 bytes per hop
|
_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
|
### 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.
|
- **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.
|
- **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<int> 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`.
|
- **Contact storage**: Path length in byte 32, raw path bytes in bytes 33-96, grouped by detected `pathHashByteWidth`.
|
||||||
|
|
||||||
### UI Hop Count Display
|
### UI Hop Count Display
|
||||||
|
|||||||
@@ -3854,7 +3854,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
// Path hash mode v10+ (byte 81): width = mode + 1 byte(s) per hop
|
// Path hash mode v10+ (byte 81): width = mode + 1 byte(s) per hop
|
||||||
if (frame.length >= 82) {
|
if (frame.length >= 82) {
|
||||||
final mode = (frame[81] & 0xFF).clamp(0, 2);
|
final mode = (frame[81] & 0xFF).clamp(0, 3);
|
||||||
_pathHashByteWidth = mode + 1;
|
_pathHashByteWidth = mode + 1;
|
||||||
} else {
|
} else {
|
||||||
_pathHashByteWidth = 1;
|
_pathHashByteWidth = 1;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class PathHelper {
|
|||||||
static List<Uint8List> splitPathBytes(List<int> pathBytes, int hashByteWidth) {
|
static List<Uint8List> splitPathBytes(List<int> pathBytes, int hashByteWidth) {
|
||||||
if (pathBytes.isEmpty) return const [];
|
if (pathBytes.isEmpty) return const [];
|
||||||
|
|
||||||
final width = hashByteWidth.clamp(1, 8);
|
final width = hashByteWidth.clamp(1, 4);
|
||||||
final hops = <Uint8List>[];
|
final hops = <Uint8List>[];
|
||||||
for (int i = 0; i < pathBytes.length; i += width) {
|
for (int i = 0; i < pathBytes.length; i += width) {
|
||||||
final endIdx = (i + width).clamp(0, pathBytes.length);
|
final endIdx = (i + width).clamp(0, pathBytes.length);
|
||||||
@@ -32,6 +32,21 @@ class PathHelper {
|
|||||||
return hops;
|
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<int> 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.
|
/// Resolves path bytes to contact names, supporting multi-byte hash widths.
|
||||||
|
|
||||||
/// Groups path bytes according to [hashByteWidth]:
|
/// Groups path bytes according to [hashByteWidth]:
|
||||||
|
|||||||
@@ -163,12 +163,18 @@ class ChannelMessage {
|
|||||||
final hasPath = (flags & 0x01) != 0;
|
final hasPath = (flags & 0x01) != 0;
|
||||||
reader.skipBytes(1); // Skip reserved byte
|
reader.skipBytes(1); // Skip reserved byte
|
||||||
channelIdx = reader.readByte();
|
channelIdx = reader.readByte();
|
||||||
pathLen = reader.readInt8();
|
final pathByte = reader.readUInt8();
|
||||||
txtType = reader.readByte();
|
// pathByte packs: top 2 bits = hash width mode, low 6 bits = hop count
|
||||||
if (hasPath && pathLen > 0) {
|
final packetPathHashWidth = ((pathByte & 0xC0) >> 6) + 1;
|
||||||
reader.rewind(); // Rewind to read path length again for pathBytes
|
final hopCount = pathByte & 0x3F;
|
||||||
pathBytes = reader.readBytes(pathLen);
|
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 {
|
} else {
|
||||||
channelIdx = reader.readByte();
|
channelIdx = reader.readByte();
|
||||||
pathLen = reader.readInt8();
|
pathLen = reader.readInt8();
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class Contact {
|
|||||||
String pathFormattedIdList(int hashByteWidth) {
|
String pathFormattedIdList(int hashByteWidth) {
|
||||||
final pathBytes = pathBytesForDisplay;
|
final pathBytes = pathBytesForDisplay;
|
||||||
if (pathBytes.isEmpty) return '';
|
if (pathBytes.isEmpty) return '';
|
||||||
final w = hashByteWidth.clamp(1, 8);
|
final w = hashByteWidth.clamp(1, 4);
|
||||||
final parts = <String>[];
|
final parts = <String>[];
|
||||||
for (int i = 0; i < pathBytes.length; i += w) {
|
for (int i = 0; i < pathBytes.length; i += w) {
|
||||||
final end = (i + w) <= pathBytes.length ? (i + w) : pathBytes.length;
|
final end = (i + w) <= pathBytes.length ? (i + w) : pathBytes.length;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import '../widgets/translated_message_content.dart';
|
|||||||
import '../widgets/unread_divider.dart';
|
import '../widgets/unread_divider.dart';
|
||||||
import 'channel_message_path_screen.dart';
|
import 'channel_message_path_screen.dart';
|
||||||
import 'map_screen.dart';
|
import 'map_screen.dart';
|
||||||
|
import '../helpers/path_helper.dart';
|
||||||
|
|
||||||
class ChannelChatScreen extends StatefulWidget {
|
class ChannelChatScreen extends StatefulWidget {
|
||||||
final Channel channel;
|
final Channel channel;
|
||||||
@@ -1433,8 +1434,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||||
return pathBytes
|
final width = PathHelper.detectPathHashWidth(
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
pathBytes,
|
||||||
|
fallback: context.read<MeshCoreConnector>().pathHashByteWidth,
|
||||||
|
);
|
||||||
|
return PathHelper.splitPathBytes(pathBytes, width)
|
||||||
|
.map(PathHelper.formatHopHex)
|
||||||
.join(',');
|
.join(',');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,16 +45,16 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
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 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 observedHopCount = _hopCountFromBytes(primaryPath.length, width);
|
||||||
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!, width));
|
||||||
final effectiveHopCount = observedHopCount > 0
|
final effectiveHopCount = observedHopCount > 0 ? observedHopCount : reportedHopCount;
|
||||||
? observedHopCount
|
|
||||||
: reportedHopCount;
|
|
||||||
|
|
||||||
final observedLabel = _formatObservedHops(
|
final observedLabel = _formatObservedHops(
|
||||||
observedHopCount,
|
observedHopCount,
|
||||||
@@ -76,11 +76,11 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
title: context.l10n.contacts_repeaterPathTrace,
|
title: context.l10n.contacts_repeaterPathTrace,
|
||||||
path: primaryPath,
|
path: primaryPath,
|
||||||
flipPathAround: true,
|
flipPathAround: true,
|
||||||
reversePathAround:
|
reversePathAround: !(!channelMessage && !message.isOutgoing),
|
||||||
!(!channelMessage && !message.isOutgoing),
|
pathHashByteWidth: PathHelper.detectPathHashWidth(
|
||||||
pathHashByteWidth: context
|
primaryPath,
|
||||||
.read<MeshCoreConnector>()
|
fallback: context.read<MeshCoreConnector>().pathHashByteWidth,
|
||||||
.pathHashByteWidth,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -491,7 +491,7 @@ class _ChannelMessagePathMapScreenState
|
|||||||
|
|
||||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||||
final hops = _buildPathHops(selectedPath, connector, context.l10n);
|
final hops = _buildPathHops(selectedPath, connector, context.l10n);
|
||||||
final width = connector.pathHashByteWidth.clamp(1, 8);
|
final width = connector.pathHashByteWidth.clamp(1, 4);
|
||||||
|
|
||||||
final points = <LatLng>[];
|
final points = <LatLng>[];
|
||||||
|
|
||||||
@@ -951,8 +951,7 @@ List<_PathHop> _buildPathHops(
|
|||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
) {
|
) {
|
||||||
if (pathBytes.isEmpty) return const [];
|
if (pathBytes.isEmpty) return const [];
|
||||||
|
final width = PathHelper.detectPathHashWidth(pathBytes, fallback: connector.pathHashByteWidth);
|
||||||
final width = connector.pathHashByteWidth.clamp(1, 8);
|
|
||||||
final candidatesByHashBytes = <String, List<Contact>>{};
|
final candidatesByHashBytes = <String, List<Contact>>{};
|
||||||
final allContacts = connector.allContacts;
|
final allContacts = connector.allContacts;
|
||||||
|
|
||||||
@@ -1067,7 +1066,7 @@ String _formatPathPrefixes(Uint8List pathBytes, int hashByteWidth) {
|
|||||||
|
|
||||||
int _hopCountFromBytes(int byteCount, int hashByteWidth) {
|
int _hopCountFromBytes(int byteCount, int hashByteWidth) {
|
||||||
if (byteCount <= 0) return 0;
|
if (byteCount <= 0) return 0;
|
||||||
final width = hashByteWidth.clamp(1, 8);
|
final width = hashByteWidth.clamp(1, 4);
|
||||||
return (byteCount + width - 1) ~/ width;
|
return (byteCount + width - 1) ~/ width;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1508,6 +1508,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
title: context.l10n.chat_setCustomPath,
|
title: context.l10n.chat_setCustomPath,
|
||||||
currentPathLabel: currentPathLabel,
|
currentPathLabel: currentPathLabel,
|
||||||
onRefresh: connector.isConnected ? connector.getContacts : null,
|
onRefresh: connector.isConnected ? connector.getContacts : null,
|
||||||
|
pathHashByteWidth: connector.pathHashByteWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
appLogger.info(
|
appLogger.info(
|
||||||
|
|||||||
+29
-25
@@ -10,8 +10,9 @@ import 'package:meshcore_open/widgets/app_bar.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../l10n/l10n.dart';
|
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../helpers/path_helper.dart';
|
||||||
|
import '../l10n/l10n.dart';
|
||||||
import '../models/app_settings.dart';
|
import '../models/app_settings.dart';
|
||||||
import '../models/channel.dart';
|
import '../models/channel.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
@@ -268,7 +269,7 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
)
|
)
|
||||||
.join(',');
|
.join(',');
|
||||||
final cacheKey =
|
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) {
|
if (cacheKey != _guessedLocationsCacheKey) {
|
||||||
_guessedLocationsCacheKey = cacheKey;
|
_guessedLocationsCacheKey = cacheKey;
|
||||||
_cachedGuessedLocations = settings.mapShowGuessedLocations
|
_cachedGuessedLocations = settings.mapShowGuessedLocations
|
||||||
@@ -277,6 +278,7 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
allContactsWithLocation,
|
allContactsWithLocation,
|
||||||
pathHistory,
|
pathHistory,
|
||||||
maxRangeKm,
|
maxRangeKm,
|
||||||
|
connector.pathHashByteWidth,
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
@@ -694,22 +696,8 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
List<Contact> withLocation,
|
List<Contact> withLocation,
|
||||||
PathHistoryService pathHistory,
|
PathHistoryService pathHistory,
|
||||||
double? maxRangeKm,
|
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 = <int, Contact?>{};
|
|
||||||
|
|
||||||
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>[];
|
final result = <_GuessedLocation>[];
|
||||||
|
|
||||||
for (final contact in allContacts) {
|
for (final contact in allContacts) {
|
||||||
@@ -732,13 +720,24 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
.getRecentPaths(contact.publicKeyHex)
|
.getRecentPaths(contact.publicKeyHex)
|
||||||
.map((r) => r.pathBytes),
|
.map((r) => r.pathBytes),
|
||||||
];
|
];
|
||||||
final lastHopBytes = <int>{};
|
|
||||||
for (final pathBytes in pathSets) {
|
for (final pathBytes in pathSets) {
|
||||||
if (pathBytes.isEmpty) continue;
|
if (pathBytes.isEmpty) continue;
|
||||||
final lastHop = pathBytes.last;
|
final hopWidth = PathHelper.detectPathHashWidth(
|
||||||
lastHopBytes.add(lastHop);
|
pathBytes,
|
||||||
final r = repeaterByHash[lastHop];
|
fallback: pathHashByteWidth,
|
||||||
if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!));
|
);
|
||||||
|
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.
|
// Filter anchors that are geometrically inconsistent with radio range.
|
||||||
@@ -2290,10 +2289,15 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _addToPath(BuildContext context, Contact contact, {LatLng? position}) {
|
void _addToPath(BuildContext context, Contact contact, {LatLng? position}) {
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
final hopWidth = min(
|
||||||
|
connector.pathHashByteWidth.clamp(1, pubKeySize),
|
||||||
|
contact.publicKey.length,
|
||||||
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_pathTrace.add(
|
_pathTrace.addAll(
|
||||||
contact.publicKey[0],
|
contact.publicKey.sublist(0, hopWidth),
|
||||||
); // Add first 16 bytes of public key to path trace
|
); // Add the hop-width prefix of the public key to the trace
|
||||||
_pathTraceContacts.add(
|
_pathTraceContacts.add(
|
||||||
contact.copyWith(
|
contact.copyWith(
|
||||||
latitude: position?.latitude ?? contact.latitude,
|
latitude: position?.latitude ?? contact.latitude,
|
||||||
|
|||||||
@@ -114,8 +114,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
Contact? _targetContact;
|
Contact? _targetContact;
|
||||||
|
|
||||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||||
return PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth)
|
final width = PathHelper.detectPathHashWidth(pathBytes, fallback: widget.pathHashByteWidth);
|
||||||
.map(PathHelper.formatHopHex)
|
return PathHelper.splitPathBytes(pathBytes, width)
|
||||||
|
.map(PathHelper.formatHopHex)
|
||||||
.join(',');
|
.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,28 +215,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final mirroredHops = <Uint8List>[...pathHops];
|
final mirroredHops = <Uint8List>[...pathHops];
|
||||||
final isRepeaterOrRoom = widget.targetContact?.type == advTypeRepeater ||
|
// Mirror the path without duplicating the pivot hop.
|
||||||
widget.targetContact?.type == advTypeRoom;
|
if (pathHops.length > 1) {
|
||||||
if (isRepeaterOrRoom) {
|
mirroredHops.addAll(
|
||||||
final pk = widget.targetContact?.publicKey;
|
pathHops.sublist(0, pathHops.length - 1).reversed,
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final traceBytes = <int>[];
|
final traceBytes = <int>[];
|
||||||
@@ -343,14 +327,21 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
final buffer = BufferReader(frame);
|
final buffer = BufferReader(frame);
|
||||||
try {
|
try {
|
||||||
buffer.skipBytes(2); // Skip push code and reserved byte
|
buffer.skipBytes(2); // Skip push code and reserved byte
|
||||||
int pathLength = buffer.readUInt8();
|
// Read the packed path byte: top2 bits = hash width mode, low6 bits = hop count
|
||||||
buffer.skipBytes(5); // Skip Flag byte and tag data
|
final pathByte = buffer.readUInt8();
|
||||||
buffer.skipBytes(4); // Skip auth code
|
final packetPathHashWidth = ((pathByte & 0xC0) >> 6) + 1;
|
||||||
final pathBytes = buffer.readBytes(pathLength);
|
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(
|
final pathData = PathHelper.splitPathBytes(
|
||||||
pathBytes,
|
pathBytes,
|
||||||
widget.pathHashByteWidth,
|
packetWidth,
|
||||||
);
|
);
|
||||||
|
|
||||||
List<double> snrData = buffer
|
List<double> snrData = buffer
|
||||||
.readRemainingBytes()
|
.readRemainingBytes()
|
||||||
.map((snr) => snr.toSigned(8).toDouble() / 4)
|
.map((snr) => snr.toSigned(8).toDouble() / 4)
|
||||||
@@ -368,15 +359,25 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
lastSeen: DateTime.now(),
|
lastSeen: DateTime.now(),
|
||||||
);
|
);
|
||||||
if (widget.pathContacts != null) {
|
if (widget.pathContacts != null) {
|
||||||
final hopWidth = widget.pathHashByteWidth.clamp(1, pubKeySize);
|
final orderedContacts = widget.pathContacts!;
|
||||||
pathContacts = {
|
if (orderedContacts.length == pathData.length) {
|
||||||
for (var c in widget.pathContacts!)
|
pathContacts = {
|
||||||
if (c.publicKey.length >= hopWidth)
|
for (var i = 0; i < pathData.length; i++)
|
||||||
_hopKey(Uint8List.fromList(c.publicKey.sublist(0, hopWidth))): c,
|
_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 {
|
} else {
|
||||||
final contacts = connector.allContactsUnfiltered;
|
final contacts = connector.allContactsUnfiltered;
|
||||||
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||||
|
final hopWidthPreview = pathData.isNotEmpty ? pathData.first.length : packetWidth.clamp(1, pubKeySize);
|
||||||
|
|
||||||
if (lastContact.latitude != null &&
|
if (lastContact.latitude != null &&
|
||||||
lastContact.longitude != null &&
|
lastContact.longitude != null &&
|
||||||
repeater.hasLocation &&
|
repeater.hasLocation &&
|
||||||
@@ -386,12 +387,17 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
LatLng(repeater.latitude!, repeater.longitude!),
|
LatLng(repeater.latitude!, repeater.longitude!),
|
||||||
) >
|
) >
|
||||||
_maxRepeaterMatchDistanceMeters) {
|
_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
|
return; //skip reapeaters that are far away from the last one with known GPS, to avoid false matches
|
||||||
}
|
}
|
||||||
for (final repeaterData in pathData) {
|
for (final repeaterData in pathData) {
|
||||||
final hopWidth = repeaterData.length;
|
final hopWidth = repeaterData.length;
|
||||||
|
|
||||||
if (repeater.publicKey.length < hopWidth) continue;
|
if (repeater.publicKey.length < hopWidth) continue;
|
||||||
|
|
||||||
if (listEquals(repeater.publicKey.sublist(0, hopWidth), repeaterData)) {
|
if (listEquals(repeater.publicKey.sublist(0, hopWidth), repeaterData)) {
|
||||||
|
|
||||||
pathContacts[_hopKey(repeaterData)] = repeater;
|
pathContacts[_hopKey(repeaterData)] = repeater;
|
||||||
lastContact = repeater;
|
lastContact = repeater;
|
||||||
}
|
}
|
||||||
@@ -411,7 +417,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
(c) =>
|
(c) =>
|
||||||
c.hasLocation &&
|
c.hasLocation &&
|
||||||
c.path.isNotEmpty &&
|
c.path.isNotEmpty &&
|
||||||
_hopKey(_lastHopChunk(c.path, widget.pathHashByteWidth)) ==
|
_hopKey(_lastHopChunk(c.path, packetWidth)) ==
|
||||||
hopKey,
|
hopKey,
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -458,9 +464,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
(c) =>
|
(c) =>
|
||||||
c.hasLocation &&
|
c.hasLocation &&
|
||||||
c.path.isNotEmpty &&
|
c.path.isNotEmpty &&
|
||||||
_hopKey(
|
_hopKey(_lastHopChunk(c.path, packetWidth)) ==
|
||||||
_lastHopChunk(c.path, widget.pathHashByteWidth),
|
|
||||||
) ==
|
|
||||||
lastHopKey,
|
lastHopKey,
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -846,7 +850,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
if (pathTraceData.pathData.isEmpty) {
|
if (pathTraceData.pathData.isEmpty) {
|
||||||
return context.l10n.pathTrace_you;
|
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) {
|
if (index == 0) {
|
||||||
return context.l10n.pathTrace_you;
|
return context.l10n.pathTrace_you;
|
||||||
} else {
|
} else {
|
||||||
@@ -871,7 +879,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
|||||||
if (pathTraceData.pathData.isEmpty) {
|
if (pathTraceData.pathData.isEmpty) {
|
||||||
return context.l10n.pathTrace_you;
|
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) {
|
if (index == 0) {
|
||||||
final hop = pathTraceData.pathData.first;
|
final hop = pathTraceData.pathData.first;
|
||||||
final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name;
|
final contactName = pathTraceData.pathContacts[_hopKey(hop)]?.name;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _updateTextFromContacts() {
|
void _updateTextFromContacts() {
|
||||||
final width = widget.pathHashByteWidth.clamp(1, 8);
|
final width = widget.pathHashByteWidth.clamp(1, 4);
|
||||||
final hexCharsPerHop = width * 2;
|
final hexCharsPerHop = width * 2;
|
||||||
final pathParts = _selectedContacts
|
final pathParts = _selectedContacts
|
||||||
.map((contact) {
|
.map((contact) {
|
||||||
@@ -126,7 +126,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||||||
.toList();
|
.toList();
|
||||||
final pathBytesList = <int>[];
|
final pathBytesList = <int>[];
|
||||||
final invalidPrefixes = <String>[];
|
final invalidPrefixes = <String>[];
|
||||||
final hexCharsPerHop = widget.pathHashByteWidth.clamp(1, 8) * 2;
|
final hexCharsPerHop = widget.pathHashByteWidth.clamp(1, 4) * 2;
|
||||||
|
|
||||||
for (final id in pathIds) {
|
for (final id in pathIds) {
|
||||||
if (id.length < hexCharsPerHop) {
|
if (id.length < hexCharsPerHop) {
|
||||||
@@ -320,7 +320,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
|
|||||||
style: const TextStyle(fontSize: 14),
|
style: const TextStyle(fontSize: 14),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
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),
|
style: const TextStyle(fontSize: 10),
|
||||||
),
|
),
|
||||||
trailing: isSelected
|
trailing: isSelected
|
||||||
|
|||||||
@@ -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])));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user