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