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

View File

@@ -8,6 +8,7 @@ import 'package:meshcore_client/meshcore_client.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.dart'; import '../utils/log_rx_route_decoder.dart';
class PacketLogScreen extends StatefulWidget { class PacketLogScreen extends StatefulWidget {
@@ -460,26 +461,6 @@ class _PacketLogCard extends StatelessWidget {
final isRx = log.direction == PacketDirection.rx; final isRx = log.direction == PacketDirection.rx;
final directionColor = isRx ? Colors.green : Colors.blue; final directionColor = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo; 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( return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 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), const SizedBox(height: 12),
_RouteSection( _DecodedRouteSection(log: log),
route: decodedRoute,
path: resolvedPath ?? const [],
originalSender: originalSender,
),
], ],
const SizedBox(height: 12), const SizedBox(height: 12),
Container( 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 { class _RouteSection extends StatelessWidget {
final DecodedLogRxRoute route; final DecodedLogRxRoute route;
final List<ResolvedNodeHash> path; final List<ResolvedNodeHash> path;
@@ -802,7 +826,13 @@ class _RouteSection extends StatelessWidget {
_FactCard( _FactCard(
icon: Icons.hub, icon: Icons.hub,
label: 'Hops', 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) if (originalSender != null)
_FactCard( _FactCard(

View File

@@ -4,38 +4,49 @@ import '../models/contact.dart';
class DecodedLogRxRoute { class DecodedLogRxRoute {
final int payloadType; final int payloadType;
final List<int> pathHashes; final List<int> pathBytes;
final int hashSize;
const DecodedLogRxRoute({ const DecodedLogRxRoute({
required this.payloadType, 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 { class ResolvedNodeHash {
final int hash; final String hashHex;
final String label; final String label;
final bool isOwnNode; final bool isOwnNode;
final bool isUniqueMatch; final bool isUniqueMatch;
final int matchCount; final int matchCount;
const ResolvedNodeHash({ const ResolvedNodeHash({
required this.hash, required this.hashHex,
required this.label, required this.label,
required this.isOwnNode, required this.isOwnNode,
required this.isUniqueMatch, required this.isUniqueMatch,
required this.matchCount, required this.matchCount,
}); });
String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}'; String get hexLabel => '0x${hashHex.toUpperCase()}';
} }
class LogRxRouteDecoder { class LogRxRouteDecoder {
const LogRxRouteDecoder._(); const LogRxRouteDecoder._();
static DecodedLogRxRoute? decode(Uint8List rawData) { static DecodedLogRxRoute? decode(
Uint8List rawData, {
int? preferredHashSize,
}) {
if (rawData.length < 5 || rawData[0] != 0x88) return null; if (rawData.length < 5 || rawData[0] != 0x88) return null;
final rawPacketData = rawData.sublist(3); final rawPacketData = rawData.sublist(3);
@@ -54,28 +65,79 @@ class LogRxRouteDecoder {
if (rawPacketData.length <= index) return null; if (rawPacketData.length <= index) return null;
final pathLen = rawPacketData[index++]; final pathLen = rawPacketData[index++];
if (rawPacketData.length < index + pathLen) return null; if (rawPacketData.length < index + pathLen) return null;
final pathBytes = rawPacketData.sublist(index, index + pathLen);
final hashSize = inferHashSize(
pathBytes,
preferredHashSize: preferredHashSize,
);
return DecodedLogRxRoute( return DecodedLogRxRoute(
payloadType: payloadType, 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( static ResolvedNodeHash resolveHash(
int hash, { String hashHex, {
required Iterable<Contact> contacts, required Iterable<Contact> contacts,
Uint8List? ownPublicKey, Uint8List? ownPublicKey,
String? ownName, String? ownName,
}) { }) {
final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty final normalizedHashHex = hashHex.toLowerCase();
? ownPublicKey.first final ownKeyHex = _bytesToHex(ownPublicKey);
: null; if (ownKeyHex != null && ownKeyHex.startsWith(normalizedHashHex)) {
if (ownHash == hash) {
final ownLabel = (ownName != null && ownName.trim().isNotEmpty) final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
? '$ownName (you)' ? '$ownName (you)'
: 'You'; : 'You';
return ResolvedNodeHash( return ResolvedNodeHash(
hash: hash, hashHex: normalizedHashHex,
label: ownLabel, label: ownLabel,
isOwnNode: true, isOwnNode: true,
isUniqueMatch: true, isUniqueMatch: true,
@@ -84,12 +146,12 @@ class LogRxRouteDecoder {
} }
final matches = contacts.where((contact) { final matches = contacts.where((contact) {
return contact.publicKey.isNotEmpty && contact.publicKey.first == hash; return contact.publicKeyHex.toLowerCase().startsWith(normalizedHashHex);
}).toList(); }).toList();
if (matches.isEmpty) { if (matches.isEmpty) {
return ResolvedNodeHash( return ResolvedNodeHash(
hash: hash, hashHex: normalizedHashHex,
label: 'Unknown', label: 'Unknown',
isOwnNode: false, isOwnNode: false,
isUniqueMatch: false, isUniqueMatch: false,
@@ -99,7 +161,7 @@ class LogRxRouteDecoder {
if (matches.length == 1) { if (matches.length == 1) {
return ResolvedNodeHash( return ResolvedNodeHash(
hash: hash, hashHex: normalizedHashHex,
label: matches.first.displayName, label: matches.first.displayName,
isOwnNode: false, isOwnNode: false,
isUniqueMatch: true, isUniqueMatch: true,
@@ -119,11 +181,19 @@ class LogRxRouteDecoder {
? '$candidateNames +$extraCount' ? '$candidateNames +$extraCount'
: candidateNames; : candidateNames;
return ResolvedNodeHash( return ResolvedNodeHash(
hash: hash, hashHex: normalizedHashHex,
label: label, label: label,
isOwnNode: false, isOwnNode: false,
isUniqueMatch: false, isUniqueMatch: false,
matchCount: matches.length, 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();
}
} }

View File

@@ -10,6 +10,8 @@ import '../../models/message.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../services/mesh_map_nodes_service.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 { class MessageTraceSheet extends StatefulWidget {
final Message message; final Message message;
@@ -32,6 +34,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Future<_TraceResult> _loadTrace() async { Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final preferredHashSize = await RouteHashPreferences.getHashSize();
final packetPath = _extractPathFromPacketLogs( final packetPath = _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs, logs: connectionProvider.bleService.packetLogs,
message: widget.message, message: widget.message,
@@ -46,10 +49,14 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
var trace = _buildTraceResult( var trace = _buildTraceResult(
nodes: localNodes, nodes: localNodes,
packetPath: packetPath, packetPath: packetPath,
preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix, recipientPrefix: recipientPrefix,
); );
if (_isCompleteTrace(trace, expectedRelayCount: math.max(0, widget.message.pathLen))) { if (_isCompleteTrace(
trace,
expectedRelayCount: math.max(0, widget.message.pathLen),
)) {
return trace; return trace;
} }
@@ -59,6 +66,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
trace = _buildTraceResult( trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes), nodes: _mergeNodes(localNodes, remoteNodes),
packetPath: packetPath, packetPath: packetPath,
preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix, recipientPrefix: recipientPrefix,
); );
@@ -336,9 +344,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
if (trace.mode == TraceMode.packetPath) { if (trace.mode == TraceMode.packetPath) {
final entries = trace.matchedPathNodes.asMap().entries.map((entry) { final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
final hashHex = trace.pathHashes[entry.key] final hashHex = trace.pathHashes[entry.key].toUpperCase();
.toRadixString(16)
.padLeft(2, '0');
return _RouteDisplayEntry( return _RouteDisplayEntry(
node: entry.value, node: entry.value,
label: entry.value?.name ?? 'Unknown', label: entry.value?.name ?? 'Unknown',
@@ -428,6 +434,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
_TraceResult _buildTraceResult({ _TraceResult _buildTraceResult({
required List<MeshMapNode> nodes, required List<MeshMapNode> nodes,
required List<int>? packetPath, required List<int>? packetPath,
required int preferredHashSize,
required String? senderPrefix, required String? senderPrefix,
required String? recipientPrefix, required String? recipientPrefix,
}) { }) {
@@ -435,9 +442,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix); final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
if (packetPath != null && packetPath.isNotEmpty) { if (packetPath != null && packetPath.isNotEmpty) {
final hashSize = LogRxRouteDecoder.inferHashSize(
packetPath,
preferredHashSize: preferredHashSize,
);
final hopHashes = LogRxRouteDecoder.splitHopHashes(
packetPath,
hashSize: hashSize,
);
final matched = _matchNodesFromPathHashes( final matched = _matchNodesFromPathHashes(
nodes: nodes, nodes: nodes,
pathHashes: packetPath, pathHashes: hopHashes,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix, recipientPrefix: recipientPrefix,
); );
@@ -445,7 +460,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
mode: TraceMode.packetPath, mode: TraceMode.packetPath,
sender: senderNode, sender: senderNode,
recipient: recipientNode, recipient: recipientNode,
pathHashes: packetPath, pathHashes: hopHashes,
matchedPathNodes: matched, matchedPathNodes: matched,
); );
} }
@@ -481,7 +496,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
trace.matchedPathNodes.every((node) => node != null); trace.matchedPathNodes.every((node) => node != null);
} }
final concreteCount = trace.matchedPathNodes.whereType<MeshMapNode>().length; final concreteCount = trace.matchedPathNodes
.whereType<MeshMapNode>()
.length;
return concreteCount >= expectedRelayCount + 2; return concreteCount >= expectedRelayCount + 2;
} }
@@ -523,13 +540,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
List<MeshMapNode?> _matchNodesFromPathHashes({ List<MeshMapNode?> _matchNodesFromPathHashes({
required List<MeshMapNode> nodes, required List<MeshMapNode> nodes,
required List<int> pathHashes, required List<String> pathHashes,
required String? senderPrefix, required String? senderPrefix,
required String? recipientPrefix, required String? recipientPrefix,
}) { }) {
final result = <MeshMapNode?>[]; final result = <MeshMapNode?>[];
for (var i = 0; i < pathHashes.length; i++) { 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 final candidates = nodes
.where((n) => n.publicKey.startsWith(hashHex)) .where((n) => n.publicKey.startsWith(hashHex))
.toList(); .toList();
@@ -618,7 +635,7 @@ class _TraceResult {
final TraceMode mode; final TraceMode mode;
final MeshMapNode? sender; final MeshMapNode? sender;
final MeshMapNode? recipient; final MeshMapNode? recipient;
final List<int> pathHashes; final List<String> pathHashes;
final List<MeshMapNode?> matchedPathNodes; final List<MeshMapNode?> matchedPathNodes;
const _TraceResult({ const _TraceResult({
@@ -645,7 +662,10 @@ class _RouteDisplayEntry {
return _RouteDisplayEntry( return _RouteDisplayEntry(
node: node, node: node,
label: node.name, 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),
),
); );
} }
} }

View File

@@ -24,15 +24,39 @@ void main() {
expect(decoded, isNotNull); expect(decoded, isNotNull);
expect(decoded!.payloadType, 0x01); expect(decoded!.payloadType, 0x01);
expect(decoded.pathHashes, [0xc2, 0xba, 0x5f, 0xde]); expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.originalSenderHash, 0xc2); 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', () { group('LogRxRouteDecoder.resolveHash', () {
test('prefers own node when hash matches device key', () { test('prefers own node when hash matches device key', () {
final resolved = LogRxRouteDecoder.resolveHash( final resolved = LogRxRouteDecoder.resolveHash(
0xc2, 'c201',
contacts: const [], contacts: const [],
ownPublicKey: Uint8List.fromList([0xc2, 0x01, 0x02]), ownPublicKey: Uint8List.fromList([0xc2, 0x01, 0x02]),
ownName: 'Base', ownName: 'Base',
@@ -44,8 +68,10 @@ void main() {
test('resolves unique contact by first public key byte', () { test('resolves unique contact by first public key byte', () {
final resolved = LogRxRouteDecoder.resolveHash( final resolved = LogRxRouteDecoder.resolveHash(
0xc2, 'c211',
contacts: [_contact(name: 'Alpha', keyPrefix: 0xc2)], contacts: [
_contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]),
],
); );
expect(resolved.isUniqueMatch, isTrue); expect(resolved.isUniqueMatch, isTrue);
@@ -54,10 +80,10 @@ void main() {
test('marks ambiguous matches without pretending certainty', () { test('marks ambiguous matches without pretending certainty', () {
final resolved = LogRxRouteDecoder.resolveHash( final resolved = LogRxRouteDecoder.resolveHash(
0xc2, 'c2',
contacts: [ contacts: [
_contact(name: 'Alpha', keyPrefix: 0xc2), _contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]),
_contact(name: 'Bravo', keyPrefix: 0xc2), _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( return Contact(
publicKey: Uint8List.fromList([ publicKey: Uint8List.fromList([
keyPrefix, ...keyPrefix,
0x11, 0x11,
0x22, 0x22,
0x33, 0x33,