From 13b42f4fb46938d7bf7413c0ee83593e9117e31e Mon Sep 17 00:00:00 2001 From: Strycher Date: Sun, 2 Aug 2026 13:34:13 -0400 Subject: [PATCH] feat(#501): log 0xC5 device-UI reads, writes, and suppressed requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device-UI surface logged only refusals, so a successful read or write produced nothing at all. That gap cost a full diagnosis on 2026-08-02. When the notification scope appeared not to refresh, neither the app debug log nor a firmware serial capture could distinguish "the client never asked" from "the client asked and got an unchanged value back". Answering it needed the firmware counterpart and a serial capture, for a question the client should have been able to answer alone. - Every request logs what it sent and the frame shape. - Every reply logs what came back: the scope value, or the matrix mask and its decoded rows. - A SET reply is distinguishable from a GET reply, so a confirmed write is not mistaken for a read. - Refusals keep their existing warn-level logging, unrecognised reason codes still surface as raw hex. The important one is the third case, which did not exist before: a request that is NEVER SENT now says so and says which gate closed, with the caps2 value that closed it. Silence was the ambiguity; suppression is now explicit. No behaviour change. Logging only, at info for normal traffic and warn for refusals, tagged DeviceUI to match the existing handler logs. No flooding risk: these fire on device-info, on pane open, and on user action. Nothing polls, and no per-frame logging was added (SAFELANE ยง11 rule 10). Full suite 740 pass, analyze clean, format clean. Agent: CalmBay (session d14220d9) --- lib/connector/meshcore_connector.dart | 76 +++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 369a6ca..d2d45b8 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -676,8 +676,38 @@ class MeshCoreConnector extends ChangeNotifier { /// Ask the radio for its button matrix. No-op unless the capability bit is /// set, so an unsupported radio never sees `0xC5`. + /// True when a `0xC5` request for [what] may be sent, logging the reason when + /// it may not. + /// + /// A suppressed request is the case that cost a whole diagnosis on + /// 2026-08-02: the scope appeared not to refresh, and nothing in either the + /// app log or a firmware serial capture could distinguish "the client never + /// asked" from "the client asked and got an unchanged value". Silence is + /// ambiguous, so the suppression says which gate closed and why. (#501) + bool _deviceUiGateOpen(String what, {required bool advertised}) { + if (!deviceUiCommandLanded) { + _appDebugLogService?.info( + '$what suppressed: 0xC5 command support is compiled off', + tag: 'DeviceUI', + ); + return false; + } + if (!advertised) { + _appDebugLogService?.info( + '$what suppressed: radio does not advertise it ' + '(caps2=${_offbandCaps2 == null ? 'absent' : '0x${_offbandCaps2!.toRadixString(16).padLeft(2, '0')}'})', + tag: 'DeviceUI', + ); + return false; + } + return true; + } + Future requestButtonMatrix() async { - if (!supportsDeviceUiCommand || !supportsButtonMatrix) return; + if (!_deviceUiGateOpen('matrix GET', advertised: supportsButtonMatrix)) { + return; + } + _appDebugLogService?.info('matrix GET -> [0xC5][0x03]', tag: 'DeviceUI'); await sendFrame(buildButtonMatrixGetFrame()); } @@ -689,22 +719,40 @@ class MeshCoreConnector extends ChangeNotifier { ButtonSequence sequence, ButtonAction action, ) async { - if (!supportsDeviceUiCommand || !supportsButtonMatrix) return; + if (!_deviceUiGateOpen('matrix SET', advertised: supportsButtonMatrix)) { + return; + } _deviceUiError = null; + _appDebugLogService?.info( + 'matrix SET -> ${sequence.label} = ${action.label} ' + '[0xC5][0x04][0x${sequence.code.toRadixString(16).padLeft(2, '0')}]' + '[0x${action.code.toRadixString(16).padLeft(2, '0')}]', + tag: 'DeviceUI', + ); await sendFrame(buildButtonMatrixSetFrame(sequence, action)); } /// Ask the radio for its current notification scope. Future requestNotifyScope() async { - if (!supportsDeviceUiCommand || !supportsNotifyScope) return; + if (!_deviceUiGateOpen('scope GET', advertised: supportsNotifyScope)) { + return; + } + _appDebugLogService?.info('scope GET -> [0xC5][0x01]', tag: 'DeviceUI'); await sendFrame(buildNotifyScopeGetFrame()); } /// Set the device notification scope. As with the matrix, local state follows /// the device's reply rather than the request. Future setNotifyScope(DeviceNotifyScope scope) async { - if (!supportsDeviceUiCommand || !supportsNotifyScope) return; + if (!_deviceUiGateOpen('scope SET', advertised: supportsNotifyScope)) { + return; + } _deviceUiError = null; + _appDebugLogService?.info( + 'scope SET -> ${scope.label} ' + '[0xC5][0x02][0x${scope.code.toRadixString(16).padLeft(2, '0')}]', + tag: 'DeviceUI', + ); await sendFrame(buildNotifyScopeSetFrame(scope)); } @@ -754,8 +802,20 @@ class MeshCoreConnector extends ChangeNotifier { return; } if (reply.matrix != null) { - _buttonMatrix = reply.matrix; + final m = reply.matrix!; + _appDebugLogService?.info( + 'matrix GET reply: mask=0x${m.supportedActions.toRadixString(16).padLeft(2, '0')} ' + 'rows=${m.assignments.length} ' + '[${m.assignments.entries.map((e) => '${e.key.label}=${e.value.label}').join(', ')}]', + tag: 'DeviceUI', + ); + _buttonMatrix = m; } else if (reply.setSequence != null && reply.setAction != null) { + _appDebugLogService?.info( + 'matrix SET confirmed: ${reply.setSequence!.label} = ' + '${reply.setAction!.label}', + tag: 'DeviceUI', + ); final held = _buttonMatrix; if (held == null) { // A device-confirmed write with nothing to fold it into. Never drop it @@ -779,6 +839,12 @@ class MeshCoreConnector extends ChangeNotifier { tag: 'DeviceUI', ); } else { + _appDebugLogService?.info( + 'scope reply: ${reply.scope!.label} ' + '(sub=0x${reply.sub.toRadixString(16).padLeft(2, '0')}, ' + '${reply.sub == offbandUiScopeSet ? 'write confirmed' : 'read'})', + tag: 'DeviceUI', + ); _deviceNotifyScope = reply.scope; } notifyListeners();