Enable route hash settings

This commit is contained in:
Janez T
2026-03-08 17:00:54 +01:00
parent 6b2f18130c
commit 9f62055eef
4 changed files with 212 additions and 66 deletions
+57 -27
View File
@@ -8,6 +8,7 @@ import 'package:meshcore_client/meshcore_client.dart';
import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.dart';
class PacketLogScreen extends StatefulWidget {
@@ -460,26 +461,6 @@ class _PacketLogCard extends StatelessWidget {
final isRx = log.direction == PacketDirection.rx;
final directionColor = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
final decodedRoute = LogRxRouteDecoder.decode(log.rawData);
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName =
connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName;
final resolvedPath = decodedRoute?.pathHashes
.map(
(hash) => LogRxRouteDecoder.resolveHash(
hash,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
),
)
.toList();
final originalSender = resolvedPath != null && resolvedPath.isNotEmpty
? resolvedPath.first
: null;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -604,13 +585,9 @@ class _PacketLogCard extends StatelessWidget {
),
),
],
if (isRx && decodedRoute != null) ...[
if (isRx) ...[
const SizedBox(height: 12),
_RouteSection(
route: decodedRoute,
path: resolvedPath ?? const [],
originalSender: originalSender,
),
_DecodedRouteSection(log: log),
],
const SizedBox(height: 12),
Container(
@@ -755,6 +732,53 @@ class _PacketLogCard extends StatelessWidget {
}
}
class _DecodedRouteSection extends StatelessWidget {
final BlePacketLog log;
const _DecodedRouteSection({required this.log});
@override
Widget build(BuildContext context) {
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName =
connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName;
return FutureBuilder<int>(
future: RouteHashPreferences.getHashSize(),
builder: (context, snapshot) {
final decodedRoute = LogRxRouteDecoder.decode(
log.rawData,
preferredHashSize: snapshot.data,
);
if (decodedRoute == null) {
return const SizedBox.shrink();
}
final resolvedPath = decodedRoute.hopHashes
.map(
(hashHex) => LogRxRouteDecoder.resolveHash(
hashHex,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
),
)
.toList();
final originalSender = resolvedPath.isEmpty ? null : resolvedPath.first;
return _RouteSection(
route: decodedRoute,
path: resolvedPath,
originalSender: originalSender,
);
},
);
}
}
class _RouteSection extends StatelessWidget {
final DecodedLogRxRoute route;
final List<ResolvedNodeHash> path;
@@ -802,7 +826,13 @@ class _RouteSection extends StatelessWidget {
_FactCard(
icon: Icons.hub,
label: 'Hops',
value: '${route.pathHashes.length}',
value: '${route.hopCount}',
),
_FactCard(
icon: Icons.tag,
label: 'Hash size',
value:
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
),
if (originalSender != null)
_FactCard(
+88 -18
View File
@@ -4,38 +4,49 @@ import '../models/contact.dart';
class DecodedLogRxRoute {
final int payloadType;
final List<int> pathHashes;
final List<int> pathBytes;
final int hashSize;
const DecodedLogRxRoute({
required this.payloadType,
required this.pathHashes,
required this.pathBytes,
required this.hashSize,
});
int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first;
List<String> get hopHashes =>
LogRxRouteDecoder.splitHopHashes(pathBytes, hashSize: hashSize);
int get hopCount => hopHashes.length;
String? get originalSenderHashHex =>
hopHashes.isEmpty ? null : hopHashes.first;
}
class ResolvedNodeHash {
final int hash;
final String hashHex;
final String label;
final bool isOwnNode;
final bool isUniqueMatch;
final int matchCount;
const ResolvedNodeHash({
required this.hash,
required this.hashHex,
required this.label,
required this.isOwnNode,
required this.isUniqueMatch,
required this.matchCount,
});
String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}';
String get hexLabel => '0x${hashHex.toUpperCase()}';
}
class LogRxRouteDecoder {
const LogRxRouteDecoder._();
static DecodedLogRxRoute? decode(Uint8List rawData) {
static DecodedLogRxRoute? decode(
Uint8List rawData, {
int? preferredHashSize,
}) {
if (rawData.length < 5 || rawData[0] != 0x88) return null;
final rawPacketData = rawData.sublist(3);
@@ -54,28 +65,79 @@ class LogRxRouteDecoder {
if (rawPacketData.length <= index) return null;
final pathLen = rawPacketData[index++];
if (rawPacketData.length < index + pathLen) return null;
final pathBytes = rawPacketData.sublist(index, index + pathLen);
final hashSize = inferHashSize(
pathBytes,
preferredHashSize: preferredHashSize,
);
return DecodedLogRxRoute(
payloadType: payloadType,
pathHashes: rawPacketData.sublist(index, index + pathLen),
pathBytes: pathBytes,
hashSize: hashSize,
);
}
static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) {
if (pathBytes.isEmpty) return 1;
final normalizedPreferred =
preferredHashSize != null &&
preferredHashSize >= 1 &&
preferredHashSize <= 3
? preferredHashSize
: null;
if (normalizedPreferred != null &&
pathBytes.length % normalizedPreferred == 0) {
return normalizedPreferred;
}
for (final candidate in const [3, 2, 1]) {
if (pathBytes.length % candidate == 0) {
return candidate;
}
}
return 1;
}
static List<String> splitHopHashes(
List<int> pathBytes, {
required int hashSize,
}) {
if (pathBytes.isEmpty) return const [];
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
return pathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.toList();
}
final hops = <String>[];
for (var index = 0; index < pathBytes.length; index += hashSize) {
hops.add(
pathBytes
.sublist(index, index + hashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join(),
);
}
return hops;
}
static ResolvedNodeHash resolveHash(
int hash, {
String hashHex, {
required Iterable<Contact> contacts,
Uint8List? ownPublicKey,
String? ownName,
}) {
final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty
? ownPublicKey.first
: null;
if (ownHash == hash) {
final normalizedHashHex = hashHex.toLowerCase();
final ownKeyHex = _bytesToHex(ownPublicKey);
if (ownKeyHex != null && ownKeyHex.startsWith(normalizedHashHex)) {
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
? '$ownName (you)'
: 'You';
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: ownLabel,
isOwnNode: true,
isUniqueMatch: true,
@@ -84,12 +146,12 @@ class LogRxRouteDecoder {
}
final matches = contacts.where((contact) {
return contact.publicKey.isNotEmpty && contact.publicKey.first == hash;
return contact.publicKeyHex.toLowerCase().startsWith(normalizedHashHex);
}).toList();
if (matches.isEmpty) {
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: 'Unknown',
isOwnNode: false,
isUniqueMatch: false,
@@ -99,7 +161,7 @@ class LogRxRouteDecoder {
if (matches.length == 1) {
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: matches.first.displayName,
isOwnNode: false,
isUniqueMatch: true,
@@ -119,11 +181,19 @@ class LogRxRouteDecoder {
? '$candidateNames +$extraCount'
: candidateNames;
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: label,
isOwnNode: false,
isUniqueMatch: false,
matchCount: matches.length,
);
}
static String? _bytesToHex(Uint8List? bytes) {
if (bytes == null || bytes.isEmpty) return null;
return bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
}
+31 -11
View File
@@ -10,6 +10,8 @@ import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../services/mesh_map_nodes_service.dart';
import '../../services/route_hash_preferences.dart';
import '../../utils/log_rx_route_decoder.dart';
class MessageTraceSheet extends StatefulWidget {
final Message message;
@@ -32,6 +34,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final preferredHashSize = await RouteHashPreferences.getHashSize();
final packetPath = _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: widget.message,
@@ -46,10 +49,14 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
var trace = _buildTraceResult(
nodes: localNodes,
packetPath: packetPath,
preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
if (_isCompleteTrace(trace, expectedRelayCount: math.max(0, widget.message.pathLen))) {
if (_isCompleteTrace(
trace,
expectedRelayCount: math.max(0, widget.message.pathLen),
)) {
return trace;
}
@@ -59,6 +66,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes),
packetPath: packetPath,
preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
@@ -336,9 +344,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
if (trace.mode == TraceMode.packetPath) {
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
final hashHex = trace.pathHashes[entry.key]
.toRadixString(16)
.padLeft(2, '0');
final hashHex = trace.pathHashes[entry.key].toUpperCase();
return _RouteDisplayEntry(
node: entry.value,
label: entry.value?.name ?? 'Unknown',
@@ -428,6 +434,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
_TraceResult _buildTraceResult({
required List<MeshMapNode> nodes,
required List<int>? packetPath,
required int preferredHashSize,
required String? senderPrefix,
required String? recipientPrefix,
}) {
@@ -435,9 +442,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
if (packetPath != null && packetPath.isNotEmpty) {
final hashSize = LogRxRouteDecoder.inferHashSize(
packetPath,
preferredHashSize: preferredHashSize,
);
final hopHashes = LogRxRouteDecoder.splitHopHashes(
packetPath,
hashSize: hashSize,
);
final matched = _matchNodesFromPathHashes(
nodes: nodes,
pathHashes: packetPath,
pathHashes: hopHashes,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
@@ -445,7 +460,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
mode: TraceMode.packetPath,
sender: senderNode,
recipient: recipientNode,
pathHashes: packetPath,
pathHashes: hopHashes,
matchedPathNodes: matched,
);
}
@@ -481,7 +496,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
trace.matchedPathNodes.every((node) => node != null);
}
final concreteCount = trace.matchedPathNodes.whereType<MeshMapNode>().length;
final concreteCount = trace.matchedPathNodes
.whereType<MeshMapNode>()
.length;
return concreteCount >= expectedRelayCount + 2;
}
@@ -523,13 +540,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
List<MeshMapNode?> _matchNodesFromPathHashes({
required List<MeshMapNode> nodes,
required List<int> pathHashes,
required List<String> pathHashes,
required String? senderPrefix,
required String? recipientPrefix,
}) {
final result = <MeshMapNode?>[];
for (var i = 0; i < pathHashes.length; i++) {
final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0');
final hashHex = pathHashes[i].toLowerCase();
final candidates = nodes
.where((n) => n.publicKey.startsWith(hashHex))
.toList();
@@ -618,7 +635,7 @@ class _TraceResult {
final TraceMode mode;
final MeshMapNode? sender;
final MeshMapNode? recipient;
final List<int> pathHashes;
final List<String> pathHashes;
final List<MeshMapNode?> matchedPathNodes;
const _TraceResult({
@@ -645,7 +662,10 @@ class _RouteDisplayEntry {
return _RouteDisplayEntry(
node: node,
label: node.name,
keyLabel: node.publicKey.substring(0, math.min(12, node.publicKey.length)),
keyLabel: node.publicKey.substring(
0,
math.min(12, node.publicKey.length),
),
);
}
}
+36 -10
View File
@@ -24,15 +24,39 @@ void main() {
expect(decoded, isNotNull);
expect(decoded!.payloadType, 0x01);
expect(decoded.pathHashes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.originalSenderHash, 0xc2);
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.hashSize, 2);
expect(decoded.hopHashes, ['c2ba', '5fde']);
expect(decoded.originalSenderHashHex, 'c2ba');
});
test('uses preferred hash size when packet length is ambiguous', () {
final packet = Uint8List.fromList([
0x88,
0x37,
0xae,
0x09,
0x06,
0xaa,
0xbb,
0xcc,
0xdd,
0xee,
0xff,
]);
final decoded = LogRxRouteDecoder.decode(packet, preferredHashSize: 1);
expect(decoded, isNotNull);
expect(decoded!.hashSize, 1);
expect(decoded.hopHashes, ['aa', 'bb', 'cc', 'dd', 'ee', 'ff']);
});
});
group('LogRxRouteDecoder.resolveHash', () {
test('prefers own node when hash matches device key', () {
final resolved = LogRxRouteDecoder.resolveHash(
0xc2,
'c201',
contacts: const [],
ownPublicKey: Uint8List.fromList([0xc2, 0x01, 0x02]),
ownName: 'Base',
@@ -44,8 +68,10 @@ void main() {
test('resolves unique contact by first public key byte', () {
final resolved = LogRxRouteDecoder.resolveHash(
0xc2,
contacts: [_contact(name: 'Alpha', keyPrefix: 0xc2)],
'c211',
contacts: [
_contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]),
],
);
expect(resolved.isUniqueMatch, isTrue);
@@ -54,10 +80,10 @@ void main() {
test('marks ambiguous matches without pretending certainty', () {
final resolved = LogRxRouteDecoder.resolveHash(
0xc2,
'c2',
contacts: [
_contact(name: 'Alpha', keyPrefix: 0xc2),
_contact(name: 'Bravo', keyPrefix: 0xc2),
_contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]),
_contact(name: 'Bravo', keyPrefix: [0xc2, 0x22]),
],
);
@@ -67,10 +93,10 @@ void main() {
});
}
Contact _contact({required String name, required int keyPrefix}) {
Contact _contact({required String name, required List<int> keyPrefix}) {
return Contact(
publicKey: Uint8List.fromList([
keyPrefix,
...keyPrefix,
0x11,
0x22,
0x33,