From b26b7fcfcf5817f020646785211459e58a4fc541 Mon Sep 17 00:00:00 2001 From: Strycher Date: Thu, 11 Jun 2026 00:07:07 -0400 Subject: [PATCH] fix(#2): decode channel hop counts and group multi-byte path hashes The firmware path-length byte packs hop count (low 6 bits) and hash width (high 2 bits) into one byte. The UI used it raw, so a 4-hop/2-byte path showed as "68 hops" and the path list rendered per byte (8 entries for 4 hops), and a 2-byte region mismatched a 1-byte one. - Add pathHopCount/pathHashSizeBytes decoders (meshcore_protocol.dart) - Add ChannelMessage.hopCount / pathHashSize getters; decode at display and leave the stored pathLength untouched so the duplicate-merge in the connector stays self-consistent - Add an always-visible per-message hop-count badge in the channel chat - Group path bytes into hash-width hashes in the chat "via" line and the Message Details screen, using each packet's own width so mixed 1/2/3-byte feeds render correctly side by side Verified against live BLE traffic (mixed 1-byte and 2-byte senders). Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_protocol.dart | 6 ++ lib/models/channel_message.dart | 18 +++++ lib/screens/channel_chat_screen.dart | 51 ++++++++++-- lib/screens/channel_message_path_screen.dart | 85 ++++++++++++++------ 4 files changed, 129 insertions(+), 31 deletions(-) diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index a056f4a..66c7bb9 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -445,6 +445,12 @@ int readInt32LE(Uint8List data, int offset) { return val; } +// Path-length byte: firmware packs hop count + hash width into one byte. +// Low 6 bits = hop count (0-63); high 2 bits = hash-size mode 0..2 -> 1..3 bytes/hop. +// TX counterpart: buildSetPathHashModeFrame (CMD_SET_PATH_HASH_MODE). +int pathHopCount(int pathLenRaw) => pathLenRaw & 0x3F; +int pathHashSizeBytes(int pathLenRaw) => ((pathLenRaw >> 6) & 0x03) + 1; + // Helper to convert uint32 to hex string String ackHashToHex(int ackHash) { return ackHash.toRadixString(16).padLeft(8, '0'); diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart index 80c9705..7c95b89 100644 --- a/lib/models/channel_message.dart +++ b/lib/models/channel_message.dart @@ -88,6 +88,24 @@ class ChannelMessage { String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null; + /// Hop count decoded from the firmware path-length byte (low 6 bits). + /// The raw byte also packs hash width in its high 2 bits, so the stored + /// pathLength is NOT the hop count — use this getter for display. + /// Returns null when unknown; a negative (flood) sentinel is preserved. + int? get hopCount { + final pl = pathLength; + if (pl == null) return null; + if (pl < 0) return pl; + return pathHopCount(pl); + } + + /// Bytes per hop hash, decoded from the path-length byte (high 2 bits -> 1..3). + int get pathHashSize { + final pl = pathLength; + if (pl == null || pl < 0) return 1; + return pathHashSizeBytes(pl); + } + ChannelMessage copyWith({ ChannelMessageStatus? status, List? repeats, diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index a756c53..5fdd772 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -686,6 +686,10 @@ class _ChannelChatScreenState extends State { ], ], ), + if (message.hopCount != null && message.hopCount! > 0) ...[ + const SizedBox(height: 4), + _buildHopBadge(context, message), + ], if (enableTracing) ...[ if (displayPath.isNotEmpty) ...[ const SizedBox(height: 4), @@ -695,7 +699,10 @@ class _ChannelChatScreenState extends State { : EdgeInsets.zero, child: Text( context.l10n.channels_via( - _formatPathPrefixes(displayPath), + _formatPathPrefixes( + displayPath, + message.pathHashSize, + ), ), style: TextStyle( fontSize: 11, @@ -1437,6 +1444,31 @@ class _ChannelChatScreenState extends State { ); } + Widget _buildHopBadge(BuildContext context, ChannelMessage message) { + final hops = message.hopCount ?? 0; + final color = Theme.of(context).brightness == Brightness.dark + ? Colors.grey[400] + : Colors.grey[600]; + return InkWell( + onTap: () => _showMessagePathInfo(message), + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.route, size: 12, color: color), + const SizedBox(width: 4), + Text( + context.l10n.chat_hopsCount(hops), + style: TextStyle(fontSize: 11, color: color), + ), + ], + ), + ), + ); + } + void _showMessagePathInfo(ChannelMessage message) { Navigator.push( context, @@ -1584,10 +1616,19 @@ class _ChannelChatScreenState extends State { ); } - String _formatPathPrefixes(Uint8List pathBytes) { - return pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(','); + String _formatPathPrefixes(Uint8List pathBytes, int hashWidth) { + final w = hashWidth < 1 ? 1 : hashWidth; + final hops = []; + for (var i = 0; i < pathBytes.length; i += w) { + final end = (i + w <= pathBytes.length) ? i + w : pathBytes.length; + hops.add( + pathBytes + .sublist(i, end) + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(), + ); + } + return hops.join(','); } } diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index afb2b90..510d438 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -40,11 +40,16 @@ class ChannelMessagePathScreen extends StatelessWidget { final primaryPath = !channelMessage && !message.isOutgoing ? Uint8List.fromList(primaryPathTmp.reversed.toList()) : primaryPathTmp; - final hops = _buildPathHops(primaryPath, connector, l10n); + final hops = _buildPathHops( + primaryPath, + connector, + l10n, + message.pathHashSize, + ); final hasHopDetails = primaryPath.isNotEmpty; final observedLabel = _formatObservedHops( - primaryPath.length, - message.pathLength, + primaryPath.length ~/ message.pathHashSize, + message.hopCount, l10n, ); final extraPaths = _otherPaths(primaryPath, message.pathVariants); @@ -143,7 +148,7 @@ class ChannelMessagePathScreen extends StatelessWidget { ), _buildDetailRow( l10n.channelPath_pathLabelTitle, - _formatPathLabel(message.pathLength, l10n), + _formatPathLabel(message.hopCount, l10n), ), if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel), @@ -166,10 +171,15 @@ class ChannelMessagePathScreen extends StatelessWidget { title: Text( l10n.channelPath_observedPathTitle( i + 1, - _formatHopCount(variants[i].length, l10n), + _formatHopCount( + variants[i].length ~/ message.pathHashSize, + l10n, + ), ), ), - subtitle: Text(_formatPathPrefixes(variants[i])), + subtitle: Text( + _formatPathPrefixes(variants[i], message.pathHashSize), + ), trailing: const Icon(Icons.map_outlined, size: 20), onTap: () => _openPathMap( context, @@ -464,7 +474,12 @@ class _ChannelMessagePathMapScreenState : selectedPathTmp; final selectedIndex = _indexForPath(selectedPath, observedPaths); - final hops = _buildPathHops(selectedPath, connector, context.l10n); + final hops = _buildPathHops( + selectedPath, + connector, + context.l10n, + widget.message.pathHashSize, + ); final points = []; @@ -505,7 +520,7 @@ class _ChannelMessagePathMapScreenState ? LatLngBounds.fromPoints(points) : null; final mapKey = ValueKey( - '${_formatPathPrefixes(selectedPath)},${context.l10n.pathTrace_you}', + '${_formatPathPrefixes(selectedPath, widget.message.pathHashSize)},${context.l10n.pathTrace_you}', ); _pathDistance = _getPathDistance(points); @@ -648,7 +663,7 @@ class _ChannelMessagePathMapScreenState value: i, child: Text( '${paths[i].isPrimary ? l10n.channelPath_primaryPath(i + 1) : l10n.channelPath_pathLabel(i + 1)}' - ' • ${_formatHopCount(paths[i].pathBytes.length, l10n)}', + ' • ${_formatHopCount(paths[i].pathBytes.length ~/ widget.message.pathHashSize, l10n)}', ), ), ], @@ -662,7 +677,10 @@ class _ChannelMessagePathMapScreenState Text( l10n.channelPath_selectedPathLabel( label, - _formatPathPrefixes(selectedPath.pathBytes), + _formatPathPrefixes( + selectedPath.pathBytes, + widget.message.pathHashSize, + ), ), style: TextStyle(color: Colors.grey[700], fontSize: 12), ), @@ -716,7 +734,7 @@ class _ChannelMessagePathMapScreenState markers.add( _buildNodeLabelMarker( point: point, - label: hop.contact?.name ?? _formatPrefix(hop.prefix), + label: hop.contact?.name ?? _formatHash(hop.hash), ), ); } @@ -882,14 +900,14 @@ class _ChannelMessagePathMapScreenState class _PathHop { final int index; - final int prefix; + final Uint8List hash; final Contact? contact; final LatLng? position; final AppLocalizations l10n; const _PathHop({ required this.index, - required this.prefix, + required this.hash, required this.contact, required this.position, required this.l10n, @@ -898,7 +916,7 @@ class _PathHop { bool get hasLocation => position != null; String get displayLabel { - final prefixLabel = _formatPrefix(prefix); + final prefixLabel = _formatHash(hash); return '($prefixLabel) ${_resolveName(contact, l10n)}'; } } @@ -914,8 +932,10 @@ List<_PathHop> _buildPathHops( Uint8List pathBytes, MeshCoreConnector connector, AppLocalizations l10n, + int hashWidth, ) { if (pathBytes.isEmpty) return const []; + final w = hashWidth < 1 ? 1 : hashWidth; final candidatesByPrefix = >{}; final allContacts = connector.allContacts; for (final contact in allContacts) { @@ -938,9 +958,11 @@ List<_PathHop> _buildPathHops( var lastDistance = 0.0; var bestDistance = 0.0; final hops = <_PathHop>[]; - for (var i = 0; i < pathBytes.length; i++) { + for (var i = 0; i + w <= pathBytes.length; i += w) { + final hash = pathBytes.sublist(i, i + w); + final prefixKey = hash[0]; final searchPoint = i == 0 ? startPoint : previousPosition; - final candidates = candidatesByPrefix[pathBytes[i]]; + final candidates = candidatesByPrefix[prefixKey]; Contact? contact; if (candidates != null && candidates.isNotEmpty) { var bestIndex = 0; @@ -965,7 +987,7 @@ List<_PathHop> _buildPathHops( } contact = candidates.removeAt(bestIndex); if (candidates.isEmpty) { - candidatesByPrefix.remove(pathBytes[i]); + candidatesByPrefix.remove(prefixKey); } } @@ -977,7 +999,7 @@ List<_PathHop> _buildPathHops( if (lastDistance + bestDistance > 50000 && candidates != null && candidates.isNotEmpty) { - i--; + i -= w; lastDistance = bestDistance; continue; } @@ -985,8 +1007,8 @@ List<_PathHop> _buildPathHops( hops.add( _PathHop( - index: i + 1, - prefix: pathBytes[i], + index: (i ~/ w) + 1, + hash: hash, contact: contact, position: resolvedPosition, l10n: l10n, @@ -1005,14 +1027,25 @@ LatLng? _resolvePosition(Contact? contact) { return LatLng(latitude, longitude); } -String _formatPrefix(int prefix) { - return prefix.toRadixString(16).padLeft(2, '0').toUpperCase(); +String _formatHash(Uint8List hash) { + return hash + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(); } -String _formatPathPrefixes(Uint8List pathBytes) { - return pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(','); +String _formatPathPrefixes(Uint8List pathBytes, int hashWidth) { + final w = hashWidth < 1 ? 1 : hashWidth; + final hops = []; + for (var i = 0; i < pathBytes.length; i += w) { + final end = (i + w <= pathBytes.length) ? i + w : pathBytes.length; + hops.add( + pathBytes + .sublist(i, end) + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(), + ); + } + return hops.join(','); } String _formatHopCount(int count, AppLocalizations l10n) {