multibyte for snr_indicator and map_filtering.

This commit is contained in:
ericz
2026-05-18 19:00:07 +02:00
parent 091c754584
commit 28a5913ba3
7 changed files with 121 additions and 53 deletions
+44 -10
View File
@@ -49,15 +49,33 @@ import 'meshcore_protocol.dart';
class DirectRepeater { class DirectRepeater {
static const int maxAgeMinutes = 30; // Max age for direct repeater info static const int maxAgeMinutes = 30; // Max age for direct repeater info
final int pubkeyFirstByte; final List<int> pubkeyPrefix;
double snr; double snr;
DateTime lastUpdated; DateTime lastUpdated;
DirectRepeater({ DirectRepeater({
required this.pubkeyFirstByte, required List<int> pubkeyPrefix,
required this.snr, required this.snr,
DateTime? lastUpdated, DateTime? lastUpdated,
}) : lastUpdated = lastUpdated ?? DateTime.now(); }) : pubkeyPrefix = List.unmodifiable(pubkeyPrefix),
lastUpdated = lastUpdated ?? DateTime.now();
String get pubkeyPrefixHex => pubkeyPrefix
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join();
bool matchesPrefix(List<int> prefix) {
return pubkeyPrefix.length == prefix.length &&
listEquals(pubkeyPrefix, prefix);
}
bool matchesPathStart(List<int> pathBytes, int hashByteWidth) {
final width = hashByteWidth.clamp(1, 4).toInt();
if (pathBytes.length < width || pubkeyPrefix.length != width) {
return false;
}
return listEquals(pubkeyPrefix, pathBytes.sublist(0, width));
}
void update(double newSNR) { void update(double newSNR) {
snr = newSNR; snr = newSNR;
@@ -3853,12 +3871,16 @@ class MeshCoreConnector extends ChangeNotifier {
_clientRepeat = frame[80] != 0; _clientRepeat = frame[80] != 0;
} }
// 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
final previousPathHashByteWidth = _pathHashByteWidth;
if (frame.length >= 82) { if (frame.length >= 82) {
final mode = (frame[81] & 0xFF).clamp(0, 3); final mode = (frame[81] & 0xFF).clamp(0, 3);
_pathHashByteWidth = mode + 1; _pathHashByteWidth = mode + 1;
} else { } else {
_pathHashByteWidth = 1; _pathHashByteWidth = 1;
} }
if (_pathHashByteWidth != previousPathHashByteWidth) {
_directRepeaters.clear();
}
// Firmware reports MAX_CONTACTS / 2 for v3+ device info. // Firmware reports MAX_CONTACTS / 2 for v3+ device info.
final reportedContacts = frame[2]; final reportedContacts = frame[2];
@@ -4298,7 +4320,11 @@ class MeshCoreConnector extends ChangeNotifier {
for (var i = 0; i < _contacts.length; i++) { for (var i = 0; i < _contacts.length; i++) {
final contact = _contacts[i]; final contact = _contacts[i];
if (contact.type != advTypeChat) continue; if (contact.type != advTypeChat) continue;
if (_pathMatchesContact(pathBytes, contact.publicKey, pathHashWidth: pathHashWidth)) { if (_pathMatchesContact(
pathBytes,
contact.publicKey,
pathHashWidth: pathHashWidth,
)) {
matches.add(i); matches.add(i);
} }
} }
@@ -4315,7 +4341,11 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
bool _pathMatchesContact(Uint8List pathBytes, Uint8List publicKey, {int? pathHashWidth}) { bool _pathMatchesContact(
Uint8List pathBytes,
Uint8List publicKey, {
int? pathHashWidth,
}) {
final w = pathHashWidth ?? _pathHashByteWidth; final w = pathHashWidth ?? _pathHashByteWidth;
if (pathBytes.isEmpty || publicKey.length < w) return false; if (pathBytes.isEmpty || publicKey.length < w) return false;
for (int i = 0; i + w <= pathBytes.length; i += w) { for (int i = 0; i + w <= pathBytes.length; i += w) {
@@ -6170,9 +6200,13 @@ class MeshCoreConnector extends ChangeNotifier {
} }
void _updateDirectRepeater(Contact contact, double snr, Uint8List path) { void _updateDirectRepeater(Contact contact, double snr, Uint8List path) {
final pubkeyFirstByte = path.isNotEmpty final hashWidth = _pathHashByteWidth.clamp(1, 4).toInt();
? path.last final pubkeyPrefix = path.isNotEmpty
: contact.publicKey.first; ? path.sublist(math.max(0, path.length - hashWidth))
: contact.publicKey.sublist(
0,
math.min(hashWidth, contact.publicKey.length),
);
_directRepeaters.removeWhere((r) => r.isStale()); _directRepeaters.removeWhere((r) => r.isStale());
@@ -6184,7 +6218,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
final isTracked = _directRepeaters.where( final isTracked = _directRepeaters.where(
(r) => r.pubkeyFirstByte == pubkeyFirstByte, (r) => r.matchesPrefix(pubkeyPrefix),
); );
final sortedRepeaters = List<DirectRepeater>.from(_directRepeaters) final sortedRepeaters = List<DirectRepeater>.from(_directRepeaters)
@@ -6204,7 +6238,7 @@ class MeshCoreConnector extends ChangeNotifier {
repeater.update(snr); repeater.update(snr);
} else if (_directRepeaters.length < 5) { } else if (_directRepeaters.length < 5) {
_directRepeaters.add( _directRepeaters.add(
DirectRepeater(pubkeyFirstByte: pubkeyFirstByte, snr: snr), DirectRepeater(pubkeyPrefix: pubkeyPrefix, snr: snr),
); );
} }
notifyListeners(); notifyListeners();
+10 -14
View File
@@ -1,5 +1,3 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
@@ -17,7 +15,10 @@ class PathHelper {
.join(); .join();
} }
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, 4); final width = hashByteWidth.clamp(1, 4);
@@ -51,17 +52,12 @@ class PathHelper {
final hex = formatHopHex(hopBytes); final hex = formatHopHex(hopBytes);
// Find matching contacts by comparing public key prefix // Find matching contacts by comparing public key prefix
final matches = allContacts final matches = allContacts.where((c) {
.where((c) { if (c.publicKey.length < hopBytes.length) return false;
if (c.publicKey.length < hopBytes.length) return false; if (c.type != advTypeRepeater && c.type != advTypeRoom) return false;
if (c.type != advTypeRepeater && c.type != advTypeRoom) return false; // Compare bytes using listEquals for multi-byte support
// Compare bytes using listEquals for multi-byte support return listEquals(c.publicKey.sublist(0, hopBytes.length), hopBytes);
return listEquals( }).toList();
c.publicKey.sublist(0, hopBytes.length),
hopBytes,
);
})
.toList();
if (matches.isEmpty) { if (matches.isEmpty) {
parts.add(hex); parts.add(hex);
+12 -6
View File
@@ -805,16 +805,22 @@ class _ChatScreenState extends State<ChatScreen> {
pathsWithRepeaters = paths.map((path) { pathsWithRepeaters = paths.map((path) {
final isDirectRepeater = final isDirectRepeater =
directRepeater != null && directRepeater != null &&
path.pathBytes.isNotEmpty && directRepeater.matchesPathStart(
directRepeater.pubkeyFirstByte == path.pathBytes.first; path.pathBytes,
connector.pathHashByteWidth,
);
final isSecondDirectRepeater = final isSecondDirectRepeater =
secondDirectRepeater != null && secondDirectRepeater != null &&
path.pathBytes.isNotEmpty && secondDirectRepeater.matchesPathStart(
secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first; path.pathBytes,
connector.pathHashByteWidth,
);
final isThirdDirectRepeater = final isThirdDirectRepeater =
thirdDirectRepeater != null && thirdDirectRepeater != null &&
path.pathBytes.isNotEmpty && thirdDirectRepeater.matchesPathStart(
thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first; path.pathBytes,
connector.pathHashByteWidth,
);
int ranking = -1; int ranking = -1;
Color color = Colors.grey; Color color = Colors.grey;
+12 -2
View File
@@ -1266,7 +1266,7 @@ 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: _contactPathPrefix(contact, hw),
targetContact: contact, targetContact: contact,
pathHashByteWidth: hw, pathHashByteWidth: hw,
), ),
@@ -1299,7 +1299,7 @@ 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]), : _contactPathPrefix(contact, hw),
flipPathAround: contact.pathBytesForDisplay.isNotEmpty, flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
targetContact: contact, targetContact: contact,
pathHashByteWidth: hw, pathHashByteWidth: hw,
@@ -1416,6 +1416,16 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
} }
Uint8List _contactPathPrefix(Contact contact, int hashByteWidth) {
if (contact.publicKey.isEmpty) return Uint8List(0);
final width = hashByteWidth
.clamp(1, pubKeySize)
.toInt()
.clamp(1, contact.publicKey.length)
.toInt();
return Uint8List.fromList(contact.publicKey.sublist(0, width));
}
void _confirmDelete( void _confirmDelete(
BuildContext context, BuildContext context,
MeshCoreConnector connector, MeshCoreConnector connector,
+15 -2
View File
@@ -728,7 +728,10 @@ class _MapScreenState extends State<MapScreen> {
for (final repeater in withLocation) { for (final repeater in withLocation) {
if (repeater.type != advTypeRepeater) continue; if (repeater.type != advTypeRepeater) continue;
if (repeater.publicKey.length < lastHop.length) continue; if (repeater.publicKey.length < lastHop.length) continue;
if (!listEquals(repeater.publicKey.sublist(0, lastHop.length), lastHop)) { if (!listEquals(
repeater.publicKey.sublist(0, lastHop.length),
lastHop,
)) {
continue; continue;
} }
anchorSet.add(LatLng(repeater.latitude!, repeater.longitude!)); anchorSet.add(LatLng(repeater.latitude!, repeater.longitude!));
@@ -986,11 +989,21 @@ class _MapScreenState extends State<MapScreen> {
} }
if (settings.mapShowOverlaps) { if (settings.mapShowOverlaps) {
final hopWidth = context
.read<MeshCoreConnector>()
.pathHashByteWidth
.clamp(1, pubKeySize)
.toInt();
final hasOverlap = contacts final hasOverlap = contacts
.where( .where(
(c) => (c) =>
c.publicKeyHex != contact.publicKeyHex && c.publicKeyHex != contact.publicKeyHex &&
c.publicKey.first == contact.publicKey.first && c.publicKey.length >= hopWidth &&
contact.publicKey.length >= hopWidth &&
listEquals(
c.publicKey.sublist(0, hopWidth),
contact.publicKey.sublist(0, hopWidth),
) &&
(c.type == advTypeRepeater || c.type == advTypeRoom) && (c.type == advTypeRepeater || c.type == advTypeRoom) &&
(contact.type == advTypeRepeater || (contact.type == advTypeRepeater ||
contact.type == advTypeRoom), contact.type == advTypeRoom),
+12 -6
View File
@@ -202,16 +202,22 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
paths.map((path) { paths.map((path) {
final isDirectRepeater = final isDirectRepeater =
directRepeater != null && directRepeater != null &&
path.pathBytes.isNotEmpty && directRepeater.matchesPathStart(
directRepeater.pubkeyFirstByte == path.pathBytes.first; path.pathBytes,
connector.pathHashByteWidth,
);
final isSecondDirectRepeater = final isSecondDirectRepeater =
secondDirectRepeater != null && secondDirectRepeater != null &&
path.pathBytes.isNotEmpty && secondDirectRepeater.matchesPathStart(
secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first; path.pathBytes,
connector.pathHashByteWidth,
);
final isThirdDirectRepeater = final isThirdDirectRepeater =
thirdDirectRepeater != null && thirdDirectRepeater != null &&
path.pathBytes.isNotEmpty && thirdDirectRepeater.matchesPathStart(
thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first; path.pathBytes,
connector.pathHashByteWidth,
);
int ranking = -1; int ranking = -1;
Color color = Colors.grey; Color color = Colors.grey;
+14 -11
View File
@@ -1,23 +1,28 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../helpers/path_helper.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import 'signal_ui.dart'; import 'signal_ui.dart';
Contact? _getRepeaterPrefixMatchNearLocation( Contact? _getRepeaterPrefixMatchNearLocation(
List<Contact> contacts, List<Contact> contacts,
int pubkeyFirstByte, { List<int> pubkeyPrefix, {
LatLng? searchPoint, LatLng? searchPoint,
bool preferFavorites = false, bool preferFavorites = false,
}) { }) {
final candidates = contacts final candidates = contacts
.where( .where(
(c) => (c) =>
c.publicKey.isNotEmpty && c.publicKey.length >= pubkeyPrefix.length &&
c.publicKey.first == pubkeyFirstByte && listEquals(
c.publicKey.sublist(0, pubkeyPrefix.length),
pubkeyPrefix,
) &&
(c.type == advTypeRepeater || c.type == advTypeRoom), (c.type == advTypeRepeater || c.type == advTypeRoom),
) )
.toList(); .toList();
@@ -162,7 +167,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
), ),
if (directRepeater != null) if (directRepeater != null)
Text( Text(
'${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}', '${directRepeaters.length}: ${directRepeater.pubkeyPrefixHex}: ${_formatLastUpdated(directRepeater.lastUpdated)}',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -236,23 +241,21 @@ class _SNRIndicatorState extends State<SNRIndicator> {
final contact = _getRepeaterPrefixMatchNearLocation( final contact = _getRepeaterPrefixMatchNearLocation(
allContacts, allContacts,
repeater.pubkeyFirstByte, repeater.pubkeyPrefix,
searchPoint: selfPoint, searchPoint: selfPoint,
preferFavorites: true, preferFavorites: true,
); );
final name = contact?.name; final name = contact?.name;
final prefixLabel = PathHelper.formatHopHex(
repeater.pubkeyPrefix,
);
return Column( return Column(
children: [ children: [
ListTile( ListTile(
leading: Icon(snrUi.icon, color: snrUi.color), leading: Icon(snrUi.icon, color: snrUi.color),
title: Text( title: Text(name ?? prefixLabel),
name ??
repeater.pubkeyFirstByte
.toRadixString(16)
.padLeft(2, '0'),
),
subtitle: Text( subtitle: Text(
'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}', 'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}',
), ),