diff --git a/lib/helpers/path_helper.dart b/lib/helpers/path_helper.dart index 2b271e4..c4a2337 100644 --- a/lib/helpers/path_helper.dart +++ b/lib/helpers/path_helper.dart @@ -56,4 +56,77 @@ class PathHelper { } return true; } + + /// Trace `path_sz` flag for a configured hash width: floor(log2(width)), so + /// width 1→0, 2→1, 3→1, 4→2. 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 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 tracePathHops(List pathData, int hopBytes) { + final hb = hopBytes < 1 ? 1 : hopBytes; + final hops = []; + 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 buildTraceRoundTrip({ + required List routingPath, + required int routingWidth, + required int hopBytes, + required bool throughTarget, + required List targetPrefix, + }) { + final rw = routingWidth < 1 ? 1 : routingWidth; + final hb = hopBytes < 1 ? 1 : hopBytes; + final hops = >[]; + for (var i = 0; i + rw <= routingPath.length; i += rw) { + hops.add(routingPath.sublist(i, i + (hb < rw ? hb : rw))); + } + final out = []; + 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; + } } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index c4fe8e9..cf65969 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -12,6 +12,7 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../connector/meshcore_protocol.dart'; +import '../helpers/path_helper.dart'; import '../models/contact.dart'; import '../l10n/contact_localization.dart'; import '../models/contact_group.dart'; @@ -1275,7 +1276,12 @@ class _ContactsScreenState extends State MaterialPageRoute( builder: (context) => PathTraceMapScreen( title: context.l10n.contacts_repeaterPing, - path: Uint8List.fromList([contact.publicKey.first]), + path: Uint8List.fromList( + contact.publicKey.sublist( + 0, + PathHelper.traceHopBytes(hw), + ), + ), targetContact: contact, pathHashByteWidth: hw, ), @@ -1308,7 +1314,12 @@ class _ContactsScreenState extends State : context.l10n.contacts_roomPing, path: contact.pathBytesForDisplay.isNotEmpty ? contact.pathBytesForDisplay - : Uint8List.fromList([contact.publicKey.first]), + : Uint8List.fromList( + contact.publicKey.sublist( + 0, + PathHelper.traceHopBytes(hw), + ), + ), flipPathAround: contact.pathBytesForDisplay.isNotEmpty, targetContact: contact, pathHashByteWidth: hw, diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 75c9d4a..7816d39 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -12,6 +12,7 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../connector/meshcore_protocol.dart'; +import '../helpers/path_helper.dart'; import '../models/app_settings.dart'; import '../models/channel.dart'; import '../models/contact.dart'; @@ -2323,9 +2324,14 @@ class _MapScreenState extends State { void _addToPath(BuildContext context, Contact contact, {LatLng? position}) { setState(() { - _pathTrace.add( - contact.publicKey[0], - ); // Add first 16 bytes of public key to path trace + _pathTrace.addAll( + contact.publicKey.sublist( + 0, + PathHelper.traceHopBytes( + context.read().pathHashByteWidth, + ), + ), + ); // Add the configured-width hop prefix to the path trace _pathTraceContacts.add( contact.copyWith( latitude: position?.latitude ?? contact.latitude, diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index c810570..3e15297 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -7,6 +7,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_open/connector/meshcore_connector.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/models/app_settings.dart'; import 'package:meshcore_open/models/contact.dart'; @@ -41,11 +42,18 @@ class PathTraceData { final List snrData; final Map pathContacts; + /// Bytes per hop (1 << path_sz) parsed from the trace response flag. (#150) + final int hopBytes; + PathTraceData({ required this.pathData, required this.snrData, required this.pathContacts, + this.hopBytes = 1, }); + + /// Path hops as packed big-endian prefixes of [hopBytes] each. (#150) + List get hops => PathHelper.tracePathHops(pathData, hopBytes); } class PathTraceMapScreen extends StatefulWidget { @@ -188,44 +196,40 @@ class _PathTraceMapScreenState extends State { } Uint8List buildPath(Uint8List pathBytes) { - Uint8List traceBytes; + final hopBytes = PathHelper.traceHopBytes(widget.pathHashByteWidth); + final pk = widget.targetContact?.publicKey; if (pathBytes.isEmpty) { - final pk = widget.targetContact?.publicKey; - final n = widget.pathHashByteWidth.clamp(1, pubKeySize); - if (pk != null && pk.length >= n) { - return Uint8List.fromList(pk.sublist(0, n)); + // Direct target: send its own width prefix. + if (pk != null && pk.length >= hopBytes) { + return Uint8List.fromList(pk.sublist(0, hopBytes)); } - traceBytes = Uint8List(1); - traceBytes[0] = pk?[0] ?? 0; - return traceBytes; + return Uint8List.fromList([pk != null && pk.isNotEmpty ? pk[0] : 0]); } - - if (widget.targetContact?.type == advTypeRepeater || - widget.targetContact?.type == advTypeRoom) { - 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]; - } - } + // An all-zero path is the "no known path" marker → no trace payload. + if (pathBytes.every((b) => b == 0)) { + return Uint8List(0); } - return traceBytes; + + final throughTarget = + widget.targetContact?.type == advTypeRepeater || + widget.targetContact?.type == advTypeRoom; + final targetPrefix = throughTarget + ? (pk != null && pk.length >= hopBytes + ? pk.sublist(0, hopBytes) + : [pk != null && pk.isNotEmpty ? pk[0] : 0]) + : const []; + return Uint8List.fromList( + PathHelper.buildTraceRoundTrip( + routingPath: pathBytes, + routingWidth: widget.pathHashByteWidth < 1 + ? 1 + : widget.pathHashByteWidth, + hopBytes: hopBytes, + throughTarget: throughTarget, + targetPrefix: targetPrefix, + ), + ); } Future _doPathTrace() async { @@ -249,10 +253,13 @@ class _PathTraceMapScreenState extends State { ); final connector = Provider.of(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( DateTime.now().millisecondsSinceEpoch ~/ 1000, - 0, //flags - 0, //auth + 0, // auth + pathSz, // flag payload: path, ); connector.sendFrame(frame); @@ -327,9 +334,13 @@ class _PathTraceMapScreenState extends State { try { buffer.skipBytes(2); // Skip push code and reserved byte int pathLength = buffer.readUInt8(); - buffer.skipBytes(5); // Skip Flag byte and tag data - buffer.skipBytes(4); // Skip auth code + final pathSz = buffer.readUInt8() & 0x03; // flag byte → trace hop width + final hopBytes = 1 << pathSz; + buffer.skipBytes(4); // tag data + buffer.skipBytes(4); // auth code Uint8List pathData = buffer.readBytes(pathLength); + final hopList = PathHelper.tracePathHops(pathData, hopBytes); + // One SNR per hop, plus a trailing final SNR. List snrData = buffer .readRemainingBytes() .map((snr) => snr.toSigned(8).toDouble() / 4) @@ -347,7 +358,11 @@ class _PathTraceMapScreenState extends State { lastSeen: DateTime.now(), ); 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 { final contacts = connector.allContactsUnfiltered; contacts.where((c) => c.type != advTypeChat).forEach((repeater) { @@ -362,14 +377,15 @@ class _PathTraceMapScreenState extends State { _maxRepeaterMatchDistanceMeters) { return; //skip reapeaters that are far away from the last one with known GPS, to avoid false matches } - for (var repeaterData in pathData) { - if (listEquals( - repeater.publicKey.sublist(0, 1), - Uint8List.fromList([repeaterData]), - )) { - pathContacts[repeaterData] = repeater; - lastContact = repeater; - } + if (repeater.publicKey.length < hopBytes) return; + final repeaterHop = PathHelper.packPrefix( + repeater.publicKey, + 0, + hopBytes, + ); + if (hopList.contains(repeaterHop)) { + pathContacts[repeaterHop] = repeater; + lastContact = repeater; } }); } @@ -377,12 +393,20 @@ class _PathTraceMapScreenState extends State { // For hops with no GPS contact, infer position from other contacts // with known GPS that share the same last-hop byte. final Map inferredPositions = {}; - for (final hop in pathData) { + for (final hop in hopList) { final contact = pathContacts[hop]; if (contact != null && contact.hasLocation) continue; final peers = connector.contacts .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(); if (peers.isNotEmpty) { @@ -404,6 +428,7 @@ class _PathTraceMapScreenState extends State { pathData: pathData, snrData: snrData, pathContacts: pathContacts, + hopBytes: hopBytes, ); // Compute endpoint position for the target contact. LatLng? targetPos; @@ -418,16 +443,24 @@ class _PathTraceMapScreenState extends State { // Infer from the last hop: average GPS contacts sharing that 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. - final lastHop = widget.reversePathAround - ? widget.path.first - : widget.path.last; + final hb = PathHelper.traceHopBytes(widget.pathHashByteWidth); + final lastHop = widget.path.length < hb + ? 0 + : (widget.reversePathAround + ? PathHelper.packPrefix(widget.path, 0, hb) + : PathHelper.packPrefix( + widget.path, + widget.path.length - hb, + hb, + )); final peers = connector.allContacts .where( (c) => c.hasLocation && - c.path.isNotEmpty && - c.path.last == lastHop, + c.path.length >= hb && + PathHelper.packPrefix(c.path, c.path.length - hb, hb) == + lastHop, ) .toList(); if (peers.isNotEmpty) { @@ -476,7 +509,7 @@ class _PathTraceMapScreenState extends State { _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); int hopLast = 0; int hopLastLast = 0; - for (final hop in _traceData!.pathData) { + for (final hop in _traceData!.hops) { if (hop == hopLastLast && widget.flipPathAround) { break; //skip duplicate hops in round-trip paths } @@ -613,14 +646,14 @@ class _PathTraceMapScreenState extends State { } List _buildHopMarkers( - List pathData, { + List hops, { required bool showLabels, required Contact? target, }) { final markers = []; int hopLast = 0; int hopLastLast = 0; - for (final hop in pathData) { + for (final hop in hops) { final contact = _traceData!.pathContacts[hop]; final inferred = _inferredHopPositions[hop]; final hasGps = contact != null && contact.hasLocation; @@ -635,7 +668,10 @@ class _PathTraceMapScreenState extends State { final point = hasGps ? LatLng(contact.latitude!, contact.longitude!) : inferred!; - final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); + final label = hop + .toRadixString(16) + .padLeft(_traceData!.hopBytes * 2, '0') + .toUpperCase(); markers.add( Marker( @@ -807,29 +843,23 @@ class _PathTraceMapScreenState extends State { } 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) { return context.l10n.pathTrace_you; } else { - final contactName = pathTraceData - .pathContacts[pathTraceData.pathData[pathTraceData.pathData.length - - 1]] - ?.name; - final hex = pathTraceData.pathData[pathTraceData.pathData.length - 1] - .toRadixString(16) - .padLeft(2, '0') - .toUpperCase(); + final hop = hops.isNotEmpty ? hops.last : 0; + final contactName = pathTraceData.pathContacts[hop]?.name; + final hex = hop.toRadixString(16).padLeft(hexLen, '0').toUpperCase(); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; } } else { - final contactName = - pathTraceData.pathContacts[pathTraceData.pathData[index - 1]]?.name; - final hex = pathTraceData.pathData[index - 1] - .toRadixString(16) - .padLeft(2, '0') - .toUpperCase(); + final hop = index - 1 < hops.length ? hops[index - 1] : 0; + final contactName = pathTraceData.pathContacts[hop]?.name; + final hex = hop.toRadixString(16).padLeft(hexLen, '0').toUpperCase(); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; @@ -837,14 +867,13 @@ class _PathTraceMapScreenState extends State { } 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) { - final contactName = - pathTraceData.pathContacts[pathTraceData.pathData[0]]?.name; - final hex = pathTraceData.pathData[0] - .toRadixString(16) - .padLeft(2, '0') - .toUpperCase(); + final hop = hops.isNotEmpty ? hops.first : 0; + final contactName = pathTraceData.pathContacts[hop]?.name; + final hex = hop.toRadixString(16).padLeft(hexLen, '0').toUpperCase(); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; @@ -852,12 +881,9 @@ class _PathTraceMapScreenState extends State { return context.l10n.pathTrace_you; } } else { - final contactName = - pathTraceData.pathContacts[pathTraceData.pathData[index]]?.name; - final hex = pathTraceData.pathData[index] - .toRadixString(16) - .padLeft(2, '0') - .toUpperCase(); + final hop = index < hops.length ? hops[index] : 0; + final contactName = pathTraceData.pathContacts[hop]?.name; + final hex = hop.toRadixString(16).padLeft(hexLen, '0').toUpperCase(); return contactName != null ? "$hex: $contactName" : "$hex: ${context.l10n.channelPath_unknownRepeater}"; @@ -915,10 +941,10 @@ class _PathTraceMapScreenState extends State { maxZoom: 19, ), if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines), - if (_traceData!.pathData.isNotEmpty) + if (_traceData!.hops.isNotEmpty) MarkerLayer( markers: _buildHopMarkers( - _traceData!.pathData, + _traceData!.hops, showLabels: _showNodeLabels, target: target, ), @@ -934,7 +960,7 @@ class _PathTraceMapScreenState extends State { ) { final l10n = context.l10n; 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)); return Positioned( @@ -956,14 +982,14 @@ class _PathTraceMapScreenState extends State { ), const Divider(height: 1), Expanded( - child: pathTraceData.pathData.isEmpty + child: pathTraceData.hops.isEmpty ? Center( child: Text(l10n.channelPath_noHopDetailsAvailable), ) : Scrollbar( child: ListView.separated( padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: pathTraceData.pathData.length + 1, + itemCount: pathTraceData.hops.length + 1, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final snrUi = snrUiFromSNR( diff --git a/pubspec.yaml b/pubspec.yaml index 05393a4..29411b9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # 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. -version: 1.1.2-rc.1+46 +version: 1.1.2-rc.1+47 environment: sdk: ^3.9.2 diff --git a/test/helpers/path_helper_test.dart b/test/helpers/path_helper_test.dart index 1b6a0b9..3d4f61d 100644 --- a/test/helpers/path_helper_test.dart +++ b/test/helpers/path_helper_test.dart @@ -82,4 +82,81 @@ void main() { 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], + ); + }); + }); }