diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index e4e0836..2a28cc1 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -51,15 +51,23 @@ import 'meshcore_protocol.dart'; class DirectRepeater { static const int maxAgeMinutes = 30; // Max age for direct repeater info final int pubkeyFirstByte; + // Last-hop prefix at the device's configured path-hash width (1/2/3 bytes), so + // 2-/3-byte meshes display + match the full repeater prefix, not just 1 byte. (#151) + final Uint8List pubkeyPrefix; double snr; DateTime lastUpdated; DirectRepeater({ required this.pubkeyFirstByte, + required this.pubkeyPrefix, required this.snr, DateTime? lastUpdated, }) : lastUpdated = lastUpdated ?? DateTime.now(); + /// Last-hop prefix as lowercase hex (2 chars per byte). (#151) + String get prefixHex => + pubkeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + void update(double newSNR) { snr = newSNR; lastUpdated = DateTime.now(); @@ -6655,6 +6663,20 @@ class MeshCoreConnector extends ChangeNotifier { final pubkeyFirstByte = path.isNotEmpty ? path.last : contact.publicKey.first; + // Capture the last hop's FULL configured-width prefix (the directly-heard + // repeater), not just one byte, so wide-prefix meshes resolve it correctly. (#151) + final width = _pathHashByteWidth; + final Uint8List pubkeyPrefix; + if (path.isNotEmpty) { + pubkeyPrefix = Uint8List.fromList( + path.sublist(path.length >= width ? path.length - width : 0), + ); + } else { + final pk = contact.publicKey; + pubkeyPrefix = Uint8List.fromList( + pk.length >= width ? pk.sublist(0, width) : pk, + ); + } _directRepeaters.removeWhere((r) => r.isStale()); @@ -6666,7 +6688,7 @@ class MeshCoreConnector extends ChangeNotifier { } final isTracked = _directRepeaters.where( - (r) => r.pubkeyFirstByte == pubkeyFirstByte, + (r) => listEquals(r.pubkeyPrefix, pubkeyPrefix), ); final sortedRepeaters = List.from(_directRepeaters) @@ -6686,7 +6708,11 @@ class MeshCoreConnector extends ChangeNotifier { repeater.update(snr); } else if (_directRepeaters.length < 5) { _directRepeaters.add( - DirectRepeater(pubkeyFirstByte: pubkeyFirstByte, snr: snr), + DirectRepeater( + pubkeyFirstByte: pubkeyFirstByte, + pubkeyPrefix: pubkeyPrefix, + snr: snr, + ), ); } notifyListeners(); diff --git a/lib/widgets/snr_indicator.dart b/lib/widgets/snr_indicator.dart index 631d459..410a1f3 100644 --- a/lib/widgets/snr_indicator.dart +++ b/lib/widgets/snr_indicator.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; @@ -10,15 +11,18 @@ import 'signal_ui.dart'; Contact? _getRepeaterPrefixMatchNearLocation( List contacts, - int pubkeyFirstByte, { + Uint8List pubkeyPrefix, { LatLng? searchPoint, bool preferFavorites = false, }) { final candidates = contacts .where( (c) => - c.publicKey.isNotEmpty && - c.publicKey.first == pubkeyFirstByte && + c.publicKey.length >= pubkeyPrefix.length && + listEquals( + c.publicKey.sublist(0, pubkeyPrefix.length), + pubkeyPrefix, + ) && (c.type == advTypeRepeater || c.type == advTypeRoom), ) .toList(); @@ -170,7 +174,7 @@ class _SNRIndicatorState extends State { ), if (directRepeater != null) Text( - '${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}', + '${directRepeaters.length}: ${directRepeater.prefixHex}: ${_formatLastUpdated(directRepeater.lastUpdated)}', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -244,7 +248,7 @@ class _SNRIndicatorState extends State { final contact = _getRepeaterPrefixMatchNearLocation( allContacts, - repeater.pubkeyFirstByte, + repeater.pubkeyPrefix, searchPoint: selfPoint, preferFavorites: true, ); @@ -255,12 +259,7 @@ class _SNRIndicatorState extends State { children: [ ListTile( leading: Icon(snrUi.icon, color: snrUi.color), - title: Text( - name ?? - repeater.pubkeyFirstByte - .toRadixString(16) - .padLeft(2, '0'), - ), + title: Text(name ?? repeater.prefixHex), subtitle: Text( 'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}', ), diff --git a/pubspec.yaml b/pubspec.yaml index 6ba9559..d5f1b4a 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+44 +version: 1.1.2-rc.1+45 environment: sdk: ^3.9.2 diff --git a/test/connector/direct_repeater_ranking_test.dart b/test/connector/direct_repeater_ranking_test.dart index caaec69..f66718c 100644 --- a/test/connector/direct_repeater_ranking_test.dart +++ b/test/connector/direct_repeater_ranking_test.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart'; @@ -7,11 +9,13 @@ void main() { // 5 dB better SNR, but heard 24 min earlier — signal margin must win. final strongOld = DirectRepeater( pubkeyFirstByte: 1, + pubkeyPrefix: Uint8List.fromList([1]), snr: 10.0, lastUpdated: DateTime.now().subtract(const Duration(minutes: 25)), ); final weakRecent = DirectRepeater( pubkeyFirstByte: 2, + pubkeyPrefix: Uint8List.fromList([2]), snr: 5.0, lastUpdated: DateTime.now().subtract(const Duration(minutes: 1)), ); @@ -21,11 +25,13 @@ void main() { test('recency breaks ties between equal-SNR repeaters', () { final older = DirectRepeater( pubkeyFirstByte: 1, + pubkeyPrefix: Uint8List.fromList([1]), snr: 10.0, lastUpdated: DateTime.now().subtract(const Duration(minutes: 20)), ); final newer = DirectRepeater( pubkeyFirstByte: 2, + pubkeyPrefix: Uint8List.fromList([2]), snr: 10.0, lastUpdated: DateTime.now().subtract(const Duration(minutes: 1)), ); @@ -35,6 +41,7 @@ void main() { test('a stale repeater ranks -1', () { final stale = DirectRepeater( pubkeyFirstByte: 1, + pubkeyPrefix: Uint8List.fromList([1]), snr: 30.0, lastUpdated: DateTime.now().subtract(const Duration(minutes: 31)), ); @@ -46,15 +53,37 @@ void main() { // stale sentinel (-1) has to sit below every live ranking. final weakLive = DirectRepeater( pubkeyFirstByte: 1, + pubkeyPrefix: Uint8List.fromList([1]), snr: -30.0, lastUpdated: DateTime.now().subtract(const Duration(minutes: 1)), ); final strongStale = DirectRepeater( pubkeyFirstByte: 2, + pubkeyPrefix: Uint8List.fromList([2]), snr: 30.0, lastUpdated: DateTime.now().subtract(const Duration(minutes: 31)), ); expect(weakLive.ranking, greaterThan(strongStale.ranking)); }); }); + + group('DirectRepeater.prefixHex (#151)', () { + test('renders the full configured-width prefix as hex', () { + final r = DirectRepeater( + pubkeyFirstByte: 0xf4, + pubkeyPrefix: Uint8List.fromList([0xf4, 0xab]), + snr: 12.0, + ); + expect(r.prefixHex, 'f4ab'); + }); + + test('single-byte prefix renders two hex chars', () { + final r = DirectRepeater( + pubkeyFirstByte: 0x84, + pubkeyPrefix: Uint8List.fromList([0x84]), + snr: 12.0, + ); + expect(r.prefixHex, '84'); + }); + }); }