rollback-pr review

This commit is contained in:
PacoX
2026-05-17 17:44:30 +02:00
parent d66b16a4e8
commit 23f29f2cda
6 changed files with 60 additions and 141 deletions
+6 -24
View File
@@ -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` 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. On device connection, the app reads the firmware capability to set `pathHashByteWidth`:
```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, 3); final mode = modeRaw.clamp(0, 2);
_pathHashByteWidth = mode + 1; // 1..4 bytes per hop _pathHashByteWidth = mode + 1; // 1, 2, or 3 bytes per hop
``` ```
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). 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.
### Path Data Structure ### Path Data Structure
@@ -56,24 +56,6 @@ 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
-15
View File
@@ -32,21 +32,6 @@ 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]:
+2 -7
View File
@@ -36,7 +36,6 @@ 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;
@@ -1434,12 +1433,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
} }
String _formatPathPrefixes(Uint8List pathBytes) { String _formatPathPrefixes(Uint8List pathBytes) {
final width = PathHelper.detectPathHashWidth( return pathBytes
pathBytes, .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
fallback: context.read<MeshCoreConnector>().pathHashByteWidth,
);
return PathHelper.splitPathBytes(pathBytes, width)
.map(PathHelper.formatHopHex)
.join(','); .join(',');
} }
} }
+13 -12
View File
@@ -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 = primaryPath.isNotEmpty final width = connector.pathHashByteWidth.clamp(1, 4);
? 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 ? observedHopCount : reportedHopCount; final effectiveHopCount = observedHopCount > 0
? 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: !(!channelMessage && !message.isOutgoing), reversePathAround:
pathHashByteWidth: PathHelper.detectPathHashWidth( !(!channelMessage && !message.isOutgoing),
primaryPath, pathHashByteWidth: context
fallback: context.read<MeshCoreConnector>().pathHashByteWidth, .read<MeshCoreConnector>()
), .pathHashByteWidth,
), ),
), ),
), ),
@@ -951,7 +951,8 @@ 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, 4);
final candidatesByHashBytes = <String, List<Contact>>{}; final candidatesByHashBytes = <String, List<Contact>>{};
final allContacts = connector.allContacts; final allContacts = connector.allContacts;
+39 -43
View File
@@ -114,9 +114,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
Contact? _targetContact; Contact? _targetContact;
String _formatPathPrefixes(Uint8List pathBytes) { String _formatPathPrefixes(Uint8List pathBytes) {
final width = PathHelper.detectPathHashWidth(pathBytes, fallback: widget.pathHashByteWidth); return PathHelper.splitPathBytes(pathBytes, widget.pathHashByteWidth)
return PathHelper.splitPathBytes(pathBytes, width) .map(PathHelper.formatHopHex)
.map(PathHelper.formatHopHex)
.join(','); .join(',');
} }
@@ -215,11 +214,28 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
} }
final mirroredHops = <Uint8List>[...pathHops]; final mirroredHops = <Uint8List>[...pathHops];
// Mirror the path without duplicating the pivot hop. final isRepeaterOrRoom = widget.targetContact?.type == advTypeRepeater ||
if (pathHops.length > 1) { widget.targetContact?.type == advTypeRoom;
mirroredHops.addAll( if (isRepeaterOrRoom) {
pathHops.sublist(0, pathHops.length - 1).reversed, final pk = widget.targetContact?.publicKey;
); 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>[];
@@ -327,21 +343,14 @@ 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
// Read the packed path byte: top2 bits = hash width mode, low6 bits = hop count int pathLength = buffer.readUInt8();
final pathByte = buffer.readUInt8(); buffer.skipBytes(5); // Skip Flag byte and tag data
final packetPathHashWidth = ((pathByte & 0xC0) >> 6) + 1; buffer.skipBytes(4); // Skip auth code
final hopCount = pathByte & 0x3F; final pathBytes = buffer.readBytes(pathLength);
// 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,
packetWidth, widget.pathHashByteWidth,
); );
List<double> snrData = buffer List<double> snrData = buffer
.readRemainingBytes() .readRemainingBytes()
.map((snr) => snr.toSigned(8).toDouble() / 4) .map((snr) => snr.toSigned(8).toDouble() / 4)
@@ -359,25 +368,15 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
lastSeen: DateTime.now(), lastSeen: DateTime.now(),
); );
if (widget.pathContacts != null) { if (widget.pathContacts != null) {
final orderedContacts = widget.pathContacts!; final hopWidth = widget.pathHashByteWidth.clamp(1, pubKeySize);
if (orderedContacts.length == pathData.length) { pathContacts = {
pathContacts = { for (var c in widget.pathContacts!)
for (var i = 0; i < pathData.length; i++) if (c.publicKey.length >= hopWidth)
_hopKey(pathData[i]): orderedContacts[i], _hopKey(Uint8List.fromList(c.publicKey.sublist(0, hopWidth))): c,
}; };
} 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 &&
@@ -387,17 +386,12 @@ 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;
} }
@@ -417,7 +411,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
(c) => (c) =>
c.hasLocation && c.hasLocation &&
c.path.isNotEmpty && c.path.isNotEmpty &&
_hopKey(_lastHopChunk(c.path, packetWidth)) == _hopKey(_lastHopChunk(c.path, widget.pathHashByteWidth)) ==
hopKey, hopKey,
) )
.toList(); .toList();
@@ -464,7 +458,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
(c) => (c) =>
c.hasLocation && c.hasLocation &&
c.path.isNotEmpty && c.path.isNotEmpty &&
_hopKey(_lastHopChunk(c.path, packetWidth)) == _hopKey(
_lastHopChunk(c.path, widget.pathHashByteWidth),
) ==
lastHopKey, lastHopKey,
) )
.toList(); .toList();
-40
View File
@@ -1,40 +0,0 @@
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])));
});
}