fix(#150): width-matched path trace (send path_sz, build/parse width-byte hops)

The trace was width-1 end to end (flags=0, a 1-byte path, and a
1-byte-per-hop parse + render), so on a 2-/3-byte path-hash mesh it
returned 1-byte hops (`6A`, not `6Axx`) and failed to route where a
1-byte prefix is ambiguous ("Path trace not available").

- Send: path_sz = floor(log2(width)); build width-byte hops — the
  repeater/room "Ping" prefixes (contacts_screen), the round-trip
  buildPath (now mirrors by hop, not byte), and the map trace builder.
- Parse: read the flag byte for the hop width, group the hash bytes into
  packed width-byte hops, key contacts by the full prefix, 1 SNR per hop.
- Render: list panel + markers + map points iterate packed hops, labelled
  at the configured width.

PathHelper gains tracePathSz / traceHopBytes / packPrefix / tracePathHops
/ buildTraceRoundTrip, all unit-tested (incl. width-2 hop mirroring).
Bumps build to +47 for the b47 test binary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/205/head
Strycher 3 weeks ago
parent 9d632f495f
commit 7c46f1d662

@ -56,4 +56,77 @@ class PathHelper {
} }
return true; return true;
} }
/// Trace `path_sz` flag for a configured hash width: floor(log2(width)), so
/// width 10, 21, 31, 42. The wire hop size is `1 << path_sz` bytes the
/// firmware's trace widths are powers of two, so this is the largest power of
/// two the configured width (width 3 sends the leading 2 bytes per hop). (#150)
static int tracePathSz(int hashWidth) {
var w = hashWidth < 1 ? 1 : hashWidth;
var sz = 0;
while (w > 1) {
w >>= 1;
sz++;
}
return sz;
}
/// Bytes per trace hop on the wire = `1 << tracePathSz`. (#150)
static int traceHopBytes(int hashWidth) => 1 << tracePathSz(hashWidth);
/// Packs [width] bytes of [bytes] from [start] into a big-endian int, used to
/// key trace hops by their full configured-width prefix. (#150)
static int packPrefix(List<int> bytes, int start, int width) {
var v = 0;
for (var i = 0; i < width; i++) {
v = (v << 8) | bytes[start + i];
}
return v;
}
/// Splits trace [pathData] into packed big-endian hops of [hopBytes] each. A
/// trailing partial hop (malformed) is dropped. (#150)
static List<int> tracePathHops(List<int> pathData, int hopBytes) {
final hb = hopBytes < 1 ? 1 : hopBytes;
final hops = <int>[];
for (var i = 0; i + hb <= pathData.length; i += hb) {
hops.add(packPrefix(pathData, i, hb));
}
return hops;
}
/// Builds the trace round-trip payload for a non-empty routing path:
/// out-hops + (target prefix, when [throughTarget]) + reversed out-hops, each
/// hop the leading [hopBytes] of a [routingWidth]-byte routing hop. Mirrors by
/// hop, not by byte, so multi-byte hops stay intact. For a chat target the far
/// hop is the turnaround and is not duplicated. (#150)
static List<int> buildTraceRoundTrip({
required List<int> routingPath,
required int routingWidth,
required int hopBytes,
required bool throughTarget,
required List<int> targetPrefix,
}) {
final rw = routingWidth < 1 ? 1 : routingWidth;
final hb = hopBytes < 1 ? 1 : hopBytes;
final hops = <List<int>>[];
for (var i = 0; i + rw <= routingPath.length; i += rw) {
hops.add(routingPath.sublist(i, i + (hb < rw ? hb : rw)));
}
final out = <int>[];
for (final h in hops) {
out.addAll(h);
}
if (throughTarget) {
out.addAll(targetPrefix);
for (final h in hops.reversed) {
out.addAll(h);
}
} else if (hops.length >= 2) {
for (final h in hops.reversed.skip(1)) {
out.addAll(h);
}
}
return out;
}
} }

@ -12,6 +12,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../helpers/path_helper.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../l10n/contact_localization.dart'; import '../l10n/contact_localization.dart';
import '../models/contact_group.dart'; import '../models/contact_group.dart';
@ -1275,7 +1276,12 @@ class _ContactsScreenState extends State<ContactsScreen>
MaterialPageRoute( MaterialPageRoute(
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPing, title: context.l10n.contacts_repeaterPing,
path: Uint8List.fromList([contact.publicKey.first]), path: Uint8List.fromList(
contact.publicKey.sublist(
0,
PathHelper.traceHopBytes(hw),
),
),
targetContact: contact, targetContact: contact,
pathHashByteWidth: hw, pathHashByteWidth: hw,
), ),
@ -1308,7 +1314,12 @@ class _ContactsScreenState extends State<ContactsScreen>
: context.l10n.contacts_roomPing, : context.l10n.contacts_roomPing,
path: contact.pathBytesForDisplay.isNotEmpty path: contact.pathBytesForDisplay.isNotEmpty
? contact.pathBytesForDisplay ? contact.pathBytesForDisplay
: Uint8List.fromList([contact.publicKey.first]), : Uint8List.fromList(
contact.publicKey.sublist(
0,
PathHelper.traceHopBytes(hw),
),
),
flipPathAround: contact.pathBytesForDisplay.isNotEmpty, flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
targetContact: contact, targetContact: contact,
pathHashByteWidth: hw, pathHashByteWidth: hw,

@ -12,6 +12,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../helpers/path_helper.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';
@ -2323,9 +2324,14 @@ class _MapScreenState extends State<MapScreen> {
void _addToPath(BuildContext context, Contact contact, {LatLng? position}) { void _addToPath(BuildContext context, Contact contact, {LatLng? position}) {
setState(() { setState(() {
_pathTrace.add( _pathTrace.addAll(
contact.publicKey[0], contact.publicKey.sublist(
); // Add first 16 bytes of public key to path trace 0,
PathHelper.traceHopBytes(
context.read<MeshCoreConnector>().pathHashByteWidth,
),
),
); // Add the configured-width hop prefix to the path trace
_pathTraceContacts.add( _pathTraceContacts.add(
contact.copyWith( contact.copyWith(
latitude: position?.latitude ?? contact.latitude, latitude: position?.latitude ?? contact.latitude,

@ -7,6 +7,7 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart'; import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/helpers/path_helper.dart';
import 'package:meshcore_open/l10n/l10n.dart'; import 'package:meshcore_open/l10n/l10n.dart';
import 'package:meshcore_open/models/app_settings.dart'; import 'package:meshcore_open/models/app_settings.dart';
import 'package:meshcore_open/models/contact.dart'; import 'package:meshcore_open/models/contact.dart';
@ -41,11 +42,18 @@ class PathTraceData {
final List<double> snrData; final List<double> snrData;
final Map<int, Contact> pathContacts; final Map<int, Contact> pathContacts;
/// Bytes per hop (1 << path_sz) parsed from the trace response flag. (#150)
final int hopBytes;
PathTraceData({ PathTraceData({
required this.pathData, required this.pathData,
required this.snrData, required this.snrData,
required this.pathContacts, required this.pathContacts,
this.hopBytes = 1,
}); });
/// Path hops as packed big-endian prefixes of [hopBytes] each. (#150)
List<int> get hops => PathHelper.tracePathHops(pathData, hopBytes);
} }
class PathTraceMapScreen extends StatefulWidget { class PathTraceMapScreen extends StatefulWidget {
@ -188,44 +196,40 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
} }
Uint8List buildPath(Uint8List pathBytes) { Uint8List buildPath(Uint8List pathBytes) {
Uint8List traceBytes; final hopBytes = PathHelper.traceHopBytes(widget.pathHashByteWidth);
final pk = widget.targetContact?.publicKey;
if (pathBytes.isEmpty) { if (pathBytes.isEmpty) {
final pk = widget.targetContact?.publicKey; // Direct target: send its own width prefix.
final n = widget.pathHashByteWidth.clamp(1, pubKeySize); if (pk != null && pk.length >= hopBytes) {
if (pk != null && pk.length >= n) { return Uint8List.fromList(pk.sublist(0, hopBytes));
return Uint8List.fromList(pk.sublist(0, n));
} }
traceBytes = Uint8List(1); return Uint8List.fromList([pk != null && pk.isNotEmpty ? pk[0] : 0]);
traceBytes[0] = pk?[0] ?? 0;
return traceBytes;
} }
// An all-zero path is the "no known path" marker no trace payload.
if (widget.targetContact?.type == advTypeRepeater || if (pathBytes.every((b) => b == 0)) {
widget.targetContact?.type == advTypeRoom) { return Uint8List(0);
final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0;
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} else {
if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? Uint8List(0) : pathBytes;
}
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} }
return traceBytes;
final throughTarget =
widget.targetContact?.type == advTypeRepeater ||
widget.targetContact?.type == advTypeRoom;
final targetPrefix = throughTarget
? (pk != null && pk.length >= hopBytes
? pk.sublist(0, hopBytes)
: <int>[pk != null && pk.isNotEmpty ? pk[0] : 0])
: const <int>[];
return Uint8List.fromList(
PathHelper.buildTraceRoundTrip(
routingPath: pathBytes,
routingWidth: widget.pathHashByteWidth < 1
? 1
: widget.pathHashByteWidth,
hopBytes: hopBytes,
throughTarget: throughTarget,
targetPrefix: targetPrefix,
),
);
} }
Future<void> _doPathTrace() async { Future<void> _doPathTrace() async {
@ -249,10 +253,13 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
); );
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// path_sz tells the firmware the hop width; the payload hops are built at
// the matching width (1 << path_sz bytes each). (#150)
final pathSz = PathHelper.tracePathSz(widget.pathHashByteWidth);
final frame = buildTraceReq( final frame = buildTraceReq(
DateTime.now().millisecondsSinceEpoch ~/ 1000, DateTime.now().millisecondsSinceEpoch ~/ 1000,
0, //flags 0, // auth
0, //auth pathSz, // flag
payload: path, payload: path,
); );
connector.sendFrame(frame); connector.sendFrame(frame);
@ -327,9 +334,13 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
try { try {
buffer.skipBytes(2); // Skip push code and reserved byte buffer.skipBytes(2); // Skip push code and reserved byte
int pathLength = buffer.readUInt8(); int pathLength = buffer.readUInt8();
buffer.skipBytes(5); // Skip Flag byte and tag data final pathSz = buffer.readUInt8() & 0x03; // flag byte trace hop width
buffer.skipBytes(4); // Skip auth code final hopBytes = 1 << pathSz;
buffer.skipBytes(4); // tag data
buffer.skipBytes(4); // auth code
Uint8List pathData = buffer.readBytes(pathLength); Uint8List pathData = buffer.readBytes(pathLength);
final hopList = PathHelper.tracePathHops(pathData, hopBytes);
// One SNR per hop, plus a trailing final SNR.
List<double> snrData = buffer List<double> snrData = buffer
.readRemainingBytes() .readRemainingBytes()
.map((snr) => snr.toSigned(8).toDouble() / 4) .map((snr) => snr.toSigned(8).toDouble() / 4)
@ -347,7 +358,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
lastSeen: DateTime.now(), lastSeen: DateTime.now(),
); );
if (widget.pathContacts != null) { if (widget.pathContacts != null) {
pathContacts = {for (var c in widget.pathContacts!) c.publicKey[0]: c}; pathContacts = {
for (var c in widget.pathContacts!)
if (c.publicKey.length >= hopBytes)
PathHelper.packPrefix(c.publicKey, 0, hopBytes): 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) {
@ -362,14 +377,15 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
_maxRepeaterMatchDistanceMeters) { _maxRepeaterMatchDistanceMeters) {
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 (var repeaterData in pathData) { if (repeater.publicKey.length < hopBytes) return;
if (listEquals( final repeaterHop = PathHelper.packPrefix(
repeater.publicKey.sublist(0, 1), repeater.publicKey,
Uint8List.fromList([repeaterData]), 0,
)) { hopBytes,
pathContacts[repeaterData] = repeater; );
lastContact = repeater; if (hopList.contains(repeaterHop)) {
} pathContacts[repeaterHop] = repeater;
lastContact = repeater;
} }
}); });
} }
@ -377,12 +393,20 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// For hops with no GPS contact, infer position from other contacts // For hops with no GPS contact, infer position from other contacts
// with known GPS that share the same last-hop byte. // with known GPS that share the same last-hop byte.
final Map<int, LatLng> inferredPositions = {}; final Map<int, LatLng> inferredPositions = {};
for (final hop in pathData) { for (final hop in hopList) {
final contact = pathContacts[hop]; final contact = pathContacts[hop];
if (contact != null && contact.hasLocation) continue; if (contact != null && contact.hasLocation) continue;
final peers = connector.contacts final peers = connector.contacts
.where( .where(
(c) => c.hasLocation && c.path.isNotEmpty && c.path.last == hop, (c) =>
c.hasLocation &&
c.path.length >= hopBytes &&
PathHelper.packPrefix(
c.path,
c.path.length - hopBytes,
hopBytes,
) ==
hop,
) )
.toList(); .toList();
if (peers.isNotEmpty) { if (peers.isNotEmpty) {
@ -404,6 +428,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
pathData: pathData, pathData: pathData,
snrData: snrData, snrData: snrData,
pathContacts: pathContacts, pathContacts: pathContacts,
hopBytes: hopBytes,
); );
// Compute endpoint position for the target contact. // Compute endpoint position for the target contact.
LatLng? targetPos; LatLng? targetPos;
@ -418,16 +443,24 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Infer from the last hop: average GPS contacts sharing that hop. // Infer from the last hop: average GPS contacts sharing that hop.
// For a round-trip path (flipPathAround/reversePathAround), the target-side hop // For a round-trip path (flipPathAround/reversePathAround), the target-side hop
// sits in the middle of the symmetric sequence; .last is the local side. // sits in the middle of the symmetric sequence; .last is the local side.
final lastHop = widget.reversePathAround final hb = PathHelper.traceHopBytes(widget.pathHashByteWidth);
? widget.path.first final lastHop = widget.path.length < hb
: widget.path.last; ? 0
: (widget.reversePathAround
? PathHelper.packPrefix(widget.path, 0, hb)
: PathHelper.packPrefix(
widget.path,
widget.path.length - hb,
hb,
));
final peers = connector.allContacts final peers = connector.allContacts
.where( .where(
(c) => (c) =>
c.hasLocation && c.hasLocation &&
c.path.isNotEmpty && c.path.length >= hb &&
c.path.last == lastHop, PathHelper.packPrefix(c.path, c.path.length - hb, hb) ==
lastHop,
) )
.toList(); .toList();
if (peers.isNotEmpty) { if (peers.isNotEmpty) {
@ -476,7 +509,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
int hopLast = 0; int hopLast = 0;
int hopLastLast = 0; int hopLastLast = 0;
for (final hop in _traceData!.pathData) { for (final hop in _traceData!.hops) {
if (hop == hopLastLast && widget.flipPathAround) { if (hop == hopLastLast && widget.flipPathAround) {
break; //skip duplicate hops in round-trip paths break; //skip duplicate hops in round-trip paths
} }
@ -613,14 +646,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
} }
List<Marker> _buildHopMarkers( List<Marker> _buildHopMarkers(
List<int> pathData, { List<int> hops, {
required bool showLabels, required bool showLabels,
required Contact? target, required Contact? target,
}) { }) {
final markers = <Marker>[]; final markers = <Marker>[];
int hopLast = 0; int hopLast = 0;
int hopLastLast = 0; int hopLastLast = 0;
for (final hop in pathData) { for (final hop in hops) {
final contact = _traceData!.pathContacts[hop]; final contact = _traceData!.pathContacts[hop];
final inferred = _inferredHopPositions[hop]; final inferred = _inferredHopPositions[hop];
final hasGps = contact != null && contact.hasLocation; final hasGps = contact != null && contact.hasLocation;
@ -635,7 +668,10 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
final point = hasGps final point = hasGps
? LatLng(contact.latitude!, contact.longitude!) ? LatLng(contact.latitude!, contact.longitude!)
: inferred!; : inferred!;
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); final label = hop
.toRadixString(16)
.padLeft(_traceData!.hopBytes * 2, '0')
.toUpperCase();
markers.add( markers.add(
Marker( Marker(
@ -807,29 +843,23 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
} }
String formatDirectionText(PathTraceData pathTraceData, int index) { String formatDirectionText(PathTraceData pathTraceData, int index) {
final hops = pathTraceData.hops;
final hexLen = pathTraceData.hopBytes * 2;
if (index == 0 || index == pathTraceData.snrData.length - 1) { if (index == 0 || index == pathTraceData.snrData.length - 1) {
if (index == 0) { if (index == 0) {
return context.l10n.pathTrace_you; return context.l10n.pathTrace_you;
} else { } else {
final contactName = pathTraceData final hop = hops.isNotEmpty ? hops.last : 0;
.pathContacts[pathTraceData.pathData[pathTraceData.pathData.length - final contactName = pathTraceData.pathContacts[hop]?.name;
1]] final hex = hop.toRadixString(16).padLeft(hexLen, '0').toUpperCase();
?.name;
final hex = pathTraceData.pathData[pathTraceData.pathData.length - 1]
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null return contactName != null
? "$hex: $contactName" ? "$hex: $contactName"
: "$hex: ${context.l10n.channelPath_unknownRepeater}"; : "$hex: ${context.l10n.channelPath_unknownRepeater}";
} }
} else { } else {
final contactName = final hop = index - 1 < hops.length ? hops[index - 1] : 0;
pathTraceData.pathContacts[pathTraceData.pathData[index - 1]]?.name; final contactName = pathTraceData.pathContacts[hop]?.name;
final hex = pathTraceData.pathData[index - 1] final hex = hop.toRadixString(16).padLeft(hexLen, '0').toUpperCase();
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null return contactName != null
? "$hex: $contactName" ? "$hex: $contactName"
: "$hex: ${context.l10n.channelPath_unknownRepeater}"; : "$hex: ${context.l10n.channelPath_unknownRepeater}";
@ -837,14 +867,13 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
} }
String formatDirectionSubText(PathTraceData pathTraceData, int index) { String formatDirectionSubText(PathTraceData pathTraceData, int index) {
final hops = pathTraceData.hops;
final hexLen = pathTraceData.hopBytes * 2;
if (index == 0 || index == pathTraceData.snrData.length - 1) { if (index == 0 || index == pathTraceData.snrData.length - 1) {
if (index == 0) { if (index == 0) {
final contactName = final hop = hops.isNotEmpty ? hops.first : 0;
pathTraceData.pathContacts[pathTraceData.pathData[0]]?.name; final contactName = pathTraceData.pathContacts[hop]?.name;
final hex = pathTraceData.pathData[0] final hex = hop.toRadixString(16).padLeft(hexLen, '0').toUpperCase();
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null return contactName != null
? "$hex: $contactName" ? "$hex: $contactName"
: "$hex: ${context.l10n.channelPath_unknownRepeater}"; : "$hex: ${context.l10n.channelPath_unknownRepeater}";
@ -852,12 +881,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
return context.l10n.pathTrace_you; return context.l10n.pathTrace_you;
} }
} else { } else {
final contactName = final hop = index < hops.length ? hops[index] : 0;
pathTraceData.pathContacts[pathTraceData.pathData[index]]?.name; final contactName = pathTraceData.pathContacts[hop]?.name;
final hex = pathTraceData.pathData[index] final hex = hop.toRadixString(16).padLeft(hexLen, '0').toUpperCase();
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return contactName != null return contactName != null
? "$hex: $contactName" ? "$hex: $contactName"
: "$hex: ${context.l10n.channelPath_unknownRepeater}"; : "$hex: ${context.l10n.channelPath_unknownRepeater}";
@ -915,10 +941,10 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
maxZoom: 19, maxZoom: 19,
), ),
if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines), if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines),
if (_traceData!.pathData.isNotEmpty) if (_traceData!.hops.isNotEmpty)
MarkerLayer( MarkerLayer(
markers: _buildHopMarkers( markers: _buildHopMarkers(
_traceData!.pathData, _traceData!.hops,
showLabels: _showNodeLabels, showLabels: _showNodeLabels,
target: target, target: target,
), ),
@ -934,7 +960,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
) { ) {
final l10n = context.l10n; final l10n = context.l10n;
final maxHeight = MediaQuery.of(context).size.height * 0.35; final maxHeight = MediaQuery.of(context).size.height * 0.35;
final estimatedHeight = 72.0 + (pathTraceData.pathData.length * 56.0); final estimatedHeight = 72.0 + (pathTraceData.hops.length * 56.0);
final cardHeight = max(96.0, min(maxHeight, estimatedHeight)); final cardHeight = max(96.0, min(maxHeight, estimatedHeight));
return Positioned( return Positioned(
@ -956,14 +982,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: pathTraceData.pathData.isEmpty child: pathTraceData.hops.isEmpty
? Center( ? Center(
child: Text(l10n.channelPath_noHopDetailsAvailable), child: Text(l10n.channelPath_noHopDetailsAvailable),
) )
: Scrollbar( : Scrollbar(
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: pathTraceData.pathData.length + 1, itemCount: pathTraceData.hops.length + 1,
separatorBuilder: (_, _) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final snrUi = snrUiFromSNR( final snrUi = snrUiFromSNR(

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.1.2-rc.1+46 version: 1.1.2-rc.1+47
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2

@ -82,4 +82,81 @@ void main() {
expect(resolved, equals('zrepeater')); expect(resolved, equals('zrepeater'));
}); });
}); });
group('PathHelper trace-width helpers (#150)', () {
test(
'tracePathSz = floor(log2(width)); 3 falls back to the 2-byte hop',
() {
expect(PathHelper.tracePathSz(1), 0);
expect(PathHelper.tracePathSz(2), 1);
expect(PathHelper.tracePathSz(3), 1); // largest power of two <= 3 is 2
expect(PathHelper.tracePathSz(4), 2);
expect(PathHelper.tracePathSz(0), 0); // clamped to 1
},
);
test('traceHopBytes = 1 << tracePathSz', () {
expect(PathHelper.traceHopBytes(1), 1);
expect(PathHelper.traceHopBytes(2), 2);
expect(PathHelper.traceHopBytes(3), 2);
expect(PathHelper.traceHopBytes(4), 4);
});
test('packPrefix packs the leading width bytes big-endian', () {
expect(PathHelper.packPrefix([0x6a, 0xab, 0x12], 0, 1), 0x6a);
expect(PathHelper.packPrefix([0x6a, 0xab, 0x12], 0, 2), 0x6aab);
expect(PathHelper.packPrefix([0x00, 0x6a, 0xab], 1, 2), 0x6aab);
});
test('tracePathHops groups packed hops (width 1 == per-byte)', () {
expect(PathHelper.tracePathHops([0x6a, 0xab, 0x12], 1), [
0x6a,
0xab,
0x12,
]);
expect(PathHelper.tracePathHops([0x6a, 0xab, 0xc1, 0xd2], 2), [
0x6aab,
0xc1d2,
]);
});
test('buildTraceRoundTrip — repeater width 1: out + target + reverse', () {
expect(
PathHelper.buildTraceRoundTrip(
routingPath: [0x0a, 0x0b],
routingWidth: 1,
hopBytes: 1,
throughTarget: true,
targetPrefix: [0xcc],
),
[0x0a, 0x0b, 0xcc, 0x0b, 0x0a],
);
});
test('buildTraceRoundTrip — repeater width 2 mirrors by hop, not byte', () {
expect(
PathHelper.buildTraceRoundTrip(
routingPath: [0xa1, 0xa2, 0xb1, 0xb2],
routingWidth: 2,
hopBytes: 2,
throughTarget: true,
targetPrefix: [0x71, 0x72],
),
[0xa1, 0xa2, 0xb1, 0xb2, 0x71, 0x72, 0xb1, 0xb2, 0xa1, 0xa2],
);
});
test('buildTraceRoundTrip — chat: far hop is the turnaround', () {
expect(
PathHelper.buildTraceRoundTrip(
routingPath: [0x0a, 0x0b],
routingWidth: 1,
hopBytes: 1,
throughTarget: false,
targetPrefix: const [],
),
[0x0a, 0x0b, 0x0a],
);
});
});
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.