mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-07-13 06:38:49 +00:00
Enable route hash settings
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user