From 141229076897c4a41832c9b8816e69c7dd255ed6 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 18 Jul 2026 21:49:00 -0400 Subject: [PATCH] chore(#298): log path bytes + applied hash width Captures previously logged only a path byte length, making it impossible to tell one 2-byte hop from two 1-byte hops - the exact question #240 and #279 turn on. Bandit's 2026-07-18 log hit this wall. Adds a shared _pathDiag() rendering (len, applied width, derived hops, hop-grouped bytes) to the existing contact path log sites, and the same detail to the repeater/room "Login routing" lines. Also logs the path-hash width transition on every device-info frame, with the frame length, so a short-frame downgrade to width 1 (#240) is visible in any capture without needing the device. Warn on change, info otherwise. Existing log sites only - no new per-frame logging (SAFELANE 11.10). Display labels (#279) and the calculateTimeout unit bug (#299) are out of scope here. --- lib/connector/meshcore_connector.dart | 47 ++++++++++++++++++++++---- lib/widgets/repeater_login_dialog.dart | 9 ++++- lib/widgets/room_login_dialog.dart | 9 ++++- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 6fba8e9..8e4087b 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -22,6 +22,7 @@ import '../helpers/time_anomaly.dart'; import '../helpers/cyr2lat.dart'; import '../helpers/smaz.dart'; import '../helpers/cayenne_lpp.dart'; +import '../helpers/path_helper.dart'; import '../services/app_debug_log_service.dart'; import '../services/ble_debug_log_service.dart'; import '../services/linux_ble_error_classifier.dart'; @@ -491,6 +492,23 @@ class MeshCoreConnector extends ChangeNotifier { int get pathHashByteWidth => _pathHashByteWidth; + /// Compact path rendering for logs: the raw byte length, the width it is + /// being sliced at, the resulting hop count, and the hop-grouped bytes. + /// + /// Captures previously logged only the byte length, which made it impossible + /// to tell a single 2-byte hop from two 1-byte hops — the exact question + /// #240/#279 turn on. One line, existing log sites only, no new per-frame + /// logging. (#298) + String _pathDiag(List pathBytes, int byteLen) { + if (byteLen < 0) return 'flood'; + final w = _pathHashByteWidth; + final hops = realHopCount(byteLen, w); + final bytes = pathBytes.isEmpty + ? 'none' + : PathHelper.formatPathHex(pathBytes, w); + return 'len=$byteLen w=$w hops=$hops [$bytes]'; + } + CompanionRadioStats? get latestRadioStats => _latestRadioStats; bool get supportsCompanionRadioStats => (_firmwareVerCode ?? 0) >= 8; @@ -4599,7 +4617,8 @@ class MeshCoreConnector extends ChangeNotifier { _firmwareVersion = info.version; _deviceModel = info.model; _appDebugLogService?.info( - 'Device info: ${infoStrings.isEmpty ? '(no strings)' : infoStrings.join(' · ')}', + 'Device info: ${infoStrings.isEmpty ? '(no strings)' : infoStrings.join(' · ')}' + ' (frameLen=${frame.length})', tag: 'Device', ); @@ -4608,12 +4627,26 @@ class MeshCoreConnector extends ChangeNotifier { _clientRepeat = frame[80] != 0; } // Path hash mode v10+ (byte 81): width = mode + 1 byte(s) per hop + final priorPathHashByteWidth = _pathHashByteWidth; if (frame.length >= 82) { final mode = (frame[81] & 0xFF).clamp(0, 2); _pathHashByteWidth = mode + 1; } else { _pathHashByteWidth = 1; } + // A short frame silently downgrades a known-good width to 1 (#240). Log the + // frame length and the before/after width so any capture shows whether that + // happened, without needing the device. (#298) + final widthChanged = _pathHashByteWidth != priorPathHashByteWidth; + final widthNote = + 'Path hash width: $priorPathHashByteWidth -> $_pathHashByteWidth ' + '(device-info frame len=${frame.length}' + '${frame.length < 82 ? ', SHORT: no byte 81, forced to 1' : ''})'; + if (widthChanged) { + _appDebugLogService?.warn(widthNote, tag: 'Device'); + } else { + _appDebugLogService?.info(widthNote, tag: 'Device'); + } // Offband config capability v14+ (byte 82). Extracted + bounds-checked in a // testable helper; offset verified against firmware (see parseOffbandCaps). _offbandCaps = parseOffbandCaps(frame); @@ -4918,7 +4951,7 @@ class MeshCoreConnector extends ChangeNotifier { : contact.lastMessageAt; appLogger.info( - 'Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}', + 'Refreshing contact ${contact.name}: devicePath=${_pathDiag(contact.path, contact.pathLength)}, existingOverride=${existing.pathOverride}', tag: 'Connector', ); @@ -4933,7 +4966,7 @@ class MeshCoreConnector extends ChangeNotifier { ); appLogger.info( - 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', + 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_pathDiag(_contacts[existingIndex].path, _contacts[existingIndex].pathLength)}', tag: 'Connector', ); } else { @@ -4944,7 +4977,7 @@ class MeshCoreConnector extends ChangeNotifier { isContact) { _contacts.add(contact); appLogger.info( - 'Added new contact ${contact.name}: pathLen=${contact.pathLength}', + 'Added new contact ${contact.name}: pathLen=${_pathDiag(contact.path, contact.pathLength)}', tag: 'Connector', ); } else { @@ -5032,7 +5065,7 @@ class MeshCoreConnector extends ChangeNotifier { ); appLogger.info( - 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', + 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_pathDiag(_contacts[existingIndex].path, _contacts[existingIndex].pathLength)}', tag: 'Connector', ); } else { @@ -7154,7 +7187,7 @@ class MeshCoreConnector extends ChangeNotifier { : existing.lastMessageAt; appLogger.info( - 'Refreshing contact ${existing.name}: devicePath=${existing.pathLength}, existingOverride=${existing.pathOverride}', + 'Refreshing contact ${existing.name}: devicePath=${_pathDiag(existing.path, existing.pathLength)}, existingOverride=${existing.pathOverride}', tag: 'Connector', ); @@ -7180,7 +7213,7 @@ class MeshCoreConnector extends ChangeNotifier { _updateDirectRepeater(_contacts[existingIndex], snr, path); appLogger.info( - 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', + 'After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_pathDiag(_contacts[existingIndex].path, _contacts[existingIndex].pathLength)}', tag: 'Connector', ); } diff --git a/lib/widgets/repeater_login_dialog.dart b/lib/widgets/repeater_login_dialog.dart index 0973fae..468e1a7 100644 --- a/lib/widgets/repeater_login_dialog.dart +++ b/lib/widgets/repeater_login_dialog.dart @@ -11,6 +11,7 @@ import '../services/storage_service.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../utils/app_logger.dart'; +import '../helpers/path_helper.dart'; import 'path_management_dialog.dart'; class RepeaterLoginDialog extends StatefulWidget { @@ -115,9 +116,15 @@ class _RepeaterLoginDialogState extends State { ); final timeoutSeconds = (timeoutMs / 1000).ceil(); final timeout = Duration(milliseconds: timeoutMs + 2000); + // selection.hopCount is a BYTE count, not hops (#279). Log bytes, the + // applied width, the derived hop count and the hop-grouped bytes so a + // capture is unambiguous. (#298) + final routingWidth = _connector.pathHashByteWidth; final selectionLabel = selection.useFlood ? 'flood' - : '${selection.hopCount} hops'; + : 'bytes=${selection.hopCount} w=$routingWidth ' + 'hops=${realHopCount(selection.hopCount, routingWidth)} ' + '[${PathHelper.formatPathHex(selection.pathBytes, routingWidth)}]'; appLogger.info('Login routing: $selectionLabel', tag: 'RepeaterLogin'); bool? loginResult; bool isAdmin = false; diff --git a/lib/widgets/room_login_dialog.dart b/lib/widgets/room_login_dialog.dart index 2641c02..98156df 100644 --- a/lib/widgets/room_login_dialog.dart +++ b/lib/widgets/room_login_dialog.dart @@ -11,6 +11,7 @@ import '../services/storage_service.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../utils/app_logger.dart'; +import '../helpers/path_helper.dart'; import '../helpers/snack_bar_builder.dart'; import 'path_management_dialog.dart'; @@ -111,9 +112,15 @@ class _RoomLoginDialogState extends State { ); final timeoutSeconds = (timeoutMs / 1000).ceil(); final timeout = Duration(milliseconds: timeoutMs + 2000); + // selection.hopCount is a BYTE count, not hops (#279). Log bytes, the + // applied width, the derived hop count and the hop-grouped bytes so a + // capture is unambiguous. (#298) + final routingWidth = _connector.pathHashByteWidth; final selectionLabel = selection.useFlood ? 'flood' - : '${selection.hopCount} hops'; + : 'bytes=${selection.hopCount} w=$routingWidth ' + 'hops=${realHopCount(selection.hopCount, routingWidth)} ' + '[${PathHelper.formatPathHex(selection.pathBytes, routingWidth)}]'; appLogger.info('Login routing: $selectionLabel', tag: 'RoomLogin'); bool? loginResult; bool isAdmin = false;