From 4b337be586074d4021e0774e30df37ad57685565 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 1 Aug 2026 20:51:38 -0400 Subject: [PATCH] feat(#483): Button and buzzer settings for headless devices Client UI for the button-action matrix (#474) and the device notification scope (#475), under Settings > Node Settings. Speaks the canonical 0xC5 contract published by firmware in OffbandConfigProtocol.h: one command byte, sub-code selects the surface (0x01/0x02 scope get/set, 0x03/0x04 matrix get/set, 0x7F error). Not folded into 0xC0, which is observer-only and would make the feature unreachable on the headless trackers it exists for. - Notification scope All/Self/None, labelled as this radio's buzzer and kept distinct from the per-channel app notify mode. Re-read on open and on every device-info refresh so a scope changed by triple-pressing the device is never shown stale. - Button actions per press sequence, assignable only from the action set the DEVICE reports via its supported-actions mask, so a board with no buzzer or no GPS never offers a choice it would refuse. Single press defaults to unassigned. - Long press is deliberately not assignable. Firmware owns it for CLI rescue and power off, and remapping it could leave a screenless board unrecoverable. - Failures show the device's own reason via the firmware-owned reason codes, not a generic error, and the banner persists until dismissed. An unrecognised reason is surfaced with its raw code rather than swallowed. - State only ever follows the device's reply, never the request, so the UI can never show an assignment the radio rejected and nothing is faked or stored unacknowledged. - A radio advertising neither capability bit gets a diagnosis, its raw caps byte 2 and an explicit statement that no command will be sent, rather than a blank screen that is indistinguishable from a bug. caps2 bit assignment is firmware-confirmed: 0x01 notification scope, set only where PIN_BUZZER is defined, 0x02 button matrix. That is the reverse of the order the epics were filed in, so it cannot be inferred from issue numbers. 21 protocol tests: gating, frame encoding, a lying count byte, unknown sequence/action/scope codes, truncated and foreign frames, reason-code mapping, and that no sequence models a long press. NOT YET EXERCISED AGAINST A DEVICE. The firmware 0xC5 handler is written but unmerged, so until it answers, a read shows its loading row and a write is not confirmed. Hardware validation of the pair is the owner's gate and has not happened. Full suite 740 pass, analyze clean, format clean. Epic: #474, #475 Agent: CalmBay (session d14220d9) --- lib/connector/meshcore_connector.dart | 135 +++++++++ lib/connector/meshcore_protocol.dart | 26 ++ lib/connector/offband_device_ui.dart | 318 +++++++++++++++++++++ lib/screens/settings/device_ui_view.dart | 284 ++++++++++++++++++ lib/screens/settings_screen.dart | 29 ++ test/connector/offband_device_ui_test.dart | 213 ++++++++++++++ 6 files changed, 1005 insertions(+) create mode 100644 lib/connector/offband_device_ui.dart create mode 100644 lib/screens/settings/device_ui_view.dart create mode 100644 test/connector/offband_device_ui_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index a463f25..041ca11 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -58,6 +58,7 @@ import '../utils/battery_utils.dart'; import '../utils/platform_info.dart'; import 'meshcore_uuids.dart'; import 'meshcore_protocol.dart'; +import 'offband_device_ui.dart'; import 'caplog_reassembler.dart'; class DirectRepeater { @@ -296,6 +297,9 @@ class MeshCoreConnector extends ChangeNotifier { int? _offbandCaps; int? _offbandCaps2; bool? _femLnaEnabled; + ButtonMatrix? _buttonMatrix; + DeviceNotifyScope? _deviceNotifyScope; + String? _deviceUiError; int _pathHashByteWidth = 1; CompanionRadioStats? _latestRadioStats; Stopwatch? _airtimeBumpStopwatch; @@ -616,6 +620,132 @@ class MeshCoreConnector extends ChangeNotifier { /// an error: every radio predating #508 reports null here. (#480) int? get offbandCaps2 => _offbandCaps2; + /// Whether this radio exposes a configurable button-action matrix (#474). + bool get supportsButtonMatrix => firmwareSupportsButtonMatrix(_offbandCaps2); + + /// Whether this radio exposes a settable device notification scope (#475). + bool get supportsNotifyScope => firmwareSupportsNotifyScope(_offbandCaps2); + + /// Whether the radio can actually be QUERIED, as opposed to merely reporting + /// that it has the feature. False until firmware lands the get/set command, + /// so no frame is emitted that nothing will answer. The distinction is real + /// and user-visible: a T1000-E advertises the buzzer capability today but + /// cannot yet be read or written by the app. + bool get supportsDeviceUiCommand => + deviceUiCommandLanded && (supportsNotifyScope || supportsButtonMatrix); + + /// Last button matrix read from the radio, or null if not read yet. + ButtonMatrix? get buttonMatrix => _buttonMatrix; + + /// Last device notification scope read from the radio. The user can change + /// this out of band by triple-pressing the device, so it is re-read on every + /// device-info refresh rather than cached across a connection. (#475) + DeviceNotifyScope? get deviceNotifyScope => _deviceNotifyScope; + + /// The radio's own reason for refusing the last write, or null. Held until + /// the next request so the UI can show it persistently rather than as a + /// flash: an error the user cannot finish reading is not surfaced (§6). + String? get deviceUiError => _deviceUiError; + + void clearDeviceUiError() { + if (_deviceUiError == null) return; + _deviceUiError = null; + notifyListeners(); + } + + /// Ask the radio for its button matrix. No-op unless the capability bit is + /// set, so an unsupported radio never sees `0xC5`. + Future requestButtonMatrix() async { + if (!supportsDeviceUiCommand || !supportsButtonMatrix) return; + await sendFrame(buildButtonMatrixGetFrame()); + } + + /// Assign [action] to [sequence]. The radio echoes the pair on success or + /// replies with a reason; the local matrix is updated only from that reply, + /// never optimistically, so the UI can never show an assignment the device + /// rejected. + Future setButtonAction( + ButtonSequence sequence, + ButtonAction action, + ) async { + if (!supportsDeviceUiCommand || !supportsButtonMatrix) return; + _deviceUiError = null; + await sendFrame(buildButtonMatrixSetFrame(sequence, action)); + } + + /// Ask the radio for its current notification scope. + Future requestNotifyScope() async { + if (!supportsDeviceUiCommand || !supportsNotifyScope) return; + 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; + _deviceUiError = null; + await sendFrame(buildNotifyScopeSetFrame(scope)); + } + + /// Re-read whatever headless-UI state this radio supports. Called after every + /// device-info reply so a scope changed by triple-press on the device is + /// never displayed stale, and so frame-arrival order does not matter. (#475) + void _reconcileDeviceUi() { + if (supportsButtonMatrix) requestButtonMatrix(); + if (supportsNotifyScope) requestNotifyScope(); + } + + /// Single entry point for `0xC5`: both surfaces and the shared error sub-code + /// ride one command byte, so the matrix parser is tried first (it owns 0x7F) + /// and the scope parser handles what is left. + void _handleDeviceUiReply(Uint8List frame) { + if (parseButtonMatrixReply(frame) != null) { + _handleButtonMatrixReply(frame); + return; + } + _handleNotifyScopeReply(frame); + } + + void _handleButtonMatrixReply(Uint8List frame) { + final reply = parseButtonMatrixReply(frame); + if (reply == null) return; + if (reply.isError) { + _deviceUiError = reply.errorMessage; + _appDebugLogService?.warn( + 'Button config refused: ${reply.errorMessage}', + tag: 'DeviceUI', + ); + notifyListeners(); + return; + } + if (reply.matrix != null) { + _buttonMatrix = reply.matrix; + } else if (reply.setSequence != null && reply.setAction != null) { + // Fold the confirmed assignment into the matrix we already hold rather + // than re-reading the whole thing. + _buttonMatrix = _buttonMatrix?.withAssignment( + reply.setSequence!, + reply.setAction!, + ); + } + notifyListeners(); + } + + void _handleNotifyScopeReply(Uint8List frame) { + final reply = parseNotifyScopeReply(frame); + if (reply == null) return; + if (reply.isError) { + _deviceUiError = reply.errorMessage; + _appDebugLogService?.warn( + 'Notification scope refused: ${reply.errorMessage}', + tag: 'DeviceUI', + ); + } else { + _deviceNotifyScope = reply.scope; + } + notifyListeners(); + } + /// Whether the connected firmware speaks the `0xC1` GPS extension, i.e. it /// advertised the Offband-fork `offband_caps` byte. Stock MeshCore omits it, /// so 0xC1 is suppressed there rather than pinged blindly. (#144) @@ -3964,6 +4094,8 @@ class MeshCoreConnector extends ChangeNotifier { } if (value == 'gps:1' || value == 'gps:0') { _reconcileGpsPolling(); + // Byte-2 caps just landed: pull the headless-UI state they gate. (#474/#475) + _reconcileDeviceUi(); } } @@ -4666,6 +4798,9 @@ class MeshCoreConnector extends ChangeNotifier { case respCodeOffbandCaplog: _handleOffbandCaplogFrame(frame); break; + case respCodeOffbandDeviceUi: + _handleDeviceUiReply(frame); + break; case respCodeSelfInfo: debugPrint('Got SELF_INFO'); _handleSelfInfo(frame); diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 09d5901..ac3903d 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -502,6 +502,32 @@ bool firmwareSupportsOffbandBlock(int? offbandCaps, int? firmwareVerCode) => /// >= 17, the version that introduced it. const int offbandCapCaplog = 0x20; +/// `offband_caps` BYTE 2 bits (device-info frame offset 84, firmware #508). +/// +/// Bit assignment CONFIRMED by firmware (FuchsiaCreek, 2026-08-01): bit 0 is +/// the notification scope, bit 1 is the button matrix. This is the reverse of +/// the order the two epics were filed in, so do not infer it from issue +/// numbers. +/// +/// `offbandCap2NotifyScope` is set **only where `PIN_BUZZER` is defined**, the +/// same principle as FEM LNA gating on `canControlLoRaFemLna()`. Heltec V4 and +/// RAK4631 have no buzzer and will never advertise it. Gate on the BIT ONLY, +/// never on model or version code: it is a per-unit answer. +/// +/// Bit 1 is still unclaimed pending the #509 firmware PR. +const int offbandCap2NotifyScope = 0x01; +const int offbandCap2ButtonMatrix = 0x02; + +/// True iff this radio advertises a configurable button-action matrix (#474). +/// False whenever byte 2 is absent, which is every radio predating #508 and is +/// never an error. +bool firmwareSupportsButtonMatrix(int? offbandCaps2) => + offbandCaps2 != null && (offbandCaps2 & offbandCap2ButtonMatrix) != 0; + +/// True iff this radio advertises a settable device notification scope (#475). +bool firmwareSupportsNotifyScope(int? offbandCaps2) => + offbandCaps2 != null && (offbandCaps2 & offbandCap2NotifyScope) != 0; + bool firmwareSupportsOffbandCaplog(int? offbandCaps, int? firmwareVerCode) => offbandCaps != null && (offbandCaps & offbandCapCaplog) != 0 && diff --git a/lib/connector/offband_device_ui.dart b/lib/connector/offband_device_ui.dart new file mode 100644 index 0000000..e5eb837 --- /dev/null +++ b/lib/connector/offband_device_ui.dart @@ -0,0 +1,318 @@ +import 'dart:typed_data'; + +/// Headless-device UI configuration: the button-action matrix (#474) and the +/// device notification scope (#475). +/// +/// ⚠ THE COMMAND DOES NOT EXIST ON THE WIRE YET. Firmware confirmed +/// (FuchsiaCreek, 2026-08-01) that nothing currently reads or writes either +/// surface: the capability bit is discoverable, but the scope changes only by +/// triple-pressing the device, and the button matrix is unwritten. Firmware +/// asked for a client-specified shape and is building to the spec below, so +/// this file is the proposal, kept in one place so a landed contract is a +/// constant change rather than a rewrite. +/// +/// Until firmware lands it, [MeshCoreConnector.supportsDeviceUiCommand] is +/// false and no frame is ever emitted. The client can see that a radio supports +/// the feature without being able to query it, which is exactly what the UI +/// reports. +/// +/// ONE command byte covers both epics, sub-code selects the surface, so #510 +/// does not need a second allocation. 0xC5 is the next free code after 0xC0 +/// config, 0xC1 GPS, 0xC2 block, 0xC3 FEM LNA, 0xC4 caplog. +/// +/// Deliberately NOT folded into `CMD_OFFBAND_CONFIG` 0xC0: that command is +/// observer-only (its backend compiles only under `OFFBAND_OBSERVER` and the +/// client gates it on `WIFI_OBSERVER_SUPPORT`), so extending it would make the +/// feature unreachable on headless trackers, the exact boards it exists for. +/// +/// Companion-API only, NEVER on the mesh: it changes only this node's own +/// button handling and buzzer, touching no forwarding, relay, or advert path. +/// +/// scope GET `[0xC5][0x01]` -> `[0xC5][0x01][scope]` +/// SET `[0xC5][0x02][scope]` -> echo on success +/// matrix GET `[0xC5][0x03]` +/// -> `[0xC5][0x03][supportedActions][n]([sequence][action]) * n` +/// SET `[0xC5][0x04][sequence][action]` -> echo on success +/// error `[0xC5][0x7F][reason]` +/// Whether the `0xC5` get/set command exists in shipped firmware. +/// +/// TRUE: the client renders the real controls and speaks the canonical `0xC5` +/// contract published in the firmware registry. +/// +/// Owner instruction 2026-08-01: the app is where this gets configured. The +/// client is not the thing holding the feature back, so the controls are live +/// and code to the contract as written. +/// +/// Until the firmware handler answers, a read shows its loading row and a write +/// is not confirmed, because state only ever follows the device's reply and is +/// never assumed from the request. Nothing is faked and nothing is written +/// locally that the radio has not acknowledged. +const bool deviceUiCommandLanded = true; + +const int cmdOffbandDeviceUi = 0xC5; +const int respCodeOffbandDeviceUi = 0xC5; + +const int offbandUiScopeGet = 0x01; +const int offbandUiScopeSet = 0x02; +const int offbandUiMatrixGet = 0x03; +const int offbandUiMatrixSet = 0x04; + +/// Shared error sub-code. The reply carries a reason byte rather than the +/// generic `[RESP_CODE_ERR][ERR_CODE_ILLEGAL_ARG]` pair, because both epics +/// require the user to be shown *why* an assignment was refused: "this board +/// has no buzzer" and "unknown action" are different answers and a single +/// illegal-arg code cannot express them. +const int offbandUiErr = 0x7F; + +/// Why the device refused a write. Unknown values are preserved and surfaced +/// verbatim rather than collapsed into a generic failure, so firmware can add +/// reasons without the client hiding them. +enum ButtonConfigError { + unsupportedAction(0x01, 'This radio does not support that action'), + unknownSequence(0x02, 'This radio does not recognise that button sequence'), + noBuzzer(0x03, 'This radio has no buzzer'), + noGps(0x04, 'This radio has no GPS'), + malformed(0x05, 'The radio could not read the request'); + + const ButtonConfigError(this.code, this.message); + final int code; + final String message; + + static String describe(int code) { + for (final e in ButtonConfigError.values) { + if (e.code == code) return e.message; + } + // Never swallow an unrecognised reason: show the raw code so a newer + // firmware's error is still actionable rather than invisible. + return 'The radio refused the change (reason 0x' + '${code.toRadixString(16).padLeft(2, '0')})'; + } +} + +/// A button press sequence. Values are the wire encoding. +/// +/// Long-press sequences are deliberately absent: firmware reserves long-press +/// under 8 seconds for CLI rescue and 8 seconds or more for power off, and +/// making either reassignable would let a user lock themselves out of a +/// screenless device with no way back. +enum ButtonSequence { + single(0x00, 'Single press'), + double(0x01, 'Double press'), + triple(0x02, 'Triple press'), + quadruple(0x03, 'Quadruple press'); + + const ButtonSequence(this.code, this.label); + final int code; + final String label; + + static ButtonSequence? fromCode(int code) { + for (final s in ButtonSequence.values) { + if (s.code == code) return s; + } + return null; + } +} + +/// An action assignable to a sequence. The device advertises which of these it +/// actually supports; the client never offers one the radio did not claim. +enum ButtonAction { + none(0x00, 'Unassigned'), + sendAdvert(0x01, 'Send advert'), + toggleGps(0x02, 'Toggle GPS'), + cycleNotifyScope(0x03, 'Cycle notification scope'), + batteryBeep(0x04, 'Battery / status beep'); + + const ButtonAction(this.code, this.label); + final int code; + final String label; + + /// Bit position in the device's supported-actions mask. + int get mask => code == 0 ? 0 : 1 << (code - 1); + + static ButtonAction? fromCode(int code) { + for (final a in ButtonAction.values) { + if (a.code == code) return a; + } + return null; + } +} + +/// Device notification scope (#475). Governs whether the DEVICE buzzer sounds. +/// +/// Distinct from the app's per-channel [ChannelNotifyMode], which governs +/// whether the PHONE notifies. Same vocabulary deliberately; they are +/// complementary and must never be merged or made to shadow each other. +enum DeviceNotifyScope { + all(0x00, 'All', 'Beep for any received message'), + self(0x01, 'Self', 'Beep only for direct messages and @[mentions]'), + none(0x02, 'None', 'Never beep'); + + const DeviceNotifyScope(this.code, this.label, this.description); + final int code; + final String label; + final String description; + + static DeviceNotifyScope? fromCode(int code) { + for (final s in DeviceNotifyScope.values) { + if (s.code == code) return s; + } + return null; + } +} + +/// The device's current button configuration, as reported by a GET. +class ButtonMatrix { + /// Sequence to action, only for sequences the device reported. + final Map assignments; + + /// Bitmask of actions this specific radio can perform. Derived at runtime by + /// firmware from compiled-in hardware (no buzzer, no GPS), so it is a + /// per-unit answer and must never be inferred from model or version. + final int supportedActions; + + const ButtonMatrix({ + required this.assignments, + required this.supportedActions, + }); + + bool supports(ButtonAction action) => + action == ButtonAction.none || (supportedActions & action.mask) != 0; + + /// Actions this radio will accept, always including "Unassigned" so any + /// sequence can be cleared. + List get availableActions => + ButtonAction.values.where(supports).toList(); + + ButtonMatrix withAssignment(ButtonSequence seq, ButtonAction action) => + ButtonMatrix( + assignments: {...assignments, seq: action}, + supportedActions: supportedActions, + ); +} + +Uint8List buildButtonMatrixGetFrame() => + Uint8List.fromList([cmdOffbandDeviceUi, offbandUiMatrixGet]); + +Uint8List buildButtonMatrixSetFrame( + ButtonSequence sequence, + ButtonAction action, +) => Uint8List.fromList([ + cmdOffbandDeviceUi, + offbandUiMatrixSet, + sequence.code, + action.code, +]); + +Uint8List buildNotifyScopeGetFrame() => + Uint8List.fromList([cmdOffbandDeviceUi, offbandUiScopeGet]); + +Uint8List buildNotifyScopeSetFrame(DeviceNotifyScope scope) => + Uint8List.fromList([cmdOffbandDeviceUi, offbandUiScopeSet, scope.code]); + +/// Outcome of a `0xC5` / `0xC6` reply. Exactly one of the payload fields is +/// non-null; [errorMessage] is set when the device refused. +class OffbandUiReply { + final int command; + final int sub; + final ButtonMatrix? matrix; + final ButtonSequence? setSequence; + final ButtonAction? setAction; + final DeviceNotifyScope? scope; + final String? errorMessage; + + const OffbandUiReply({ + required this.command, + required this.sub, + this.matrix, + this.setSequence, + this.setAction, + this.scope, + this.errorMessage, + }); + + bool get isError => errorMessage != null; +} + +/// Parse a `0xC5` button-matrix reply. Null if this is not one. +/// +/// Every length check is explicit: a truncated or hostile frame yields null or +/// an error reply, never an out-of-range index. +OffbandUiReply? parseButtonMatrixReply(Uint8List frame) { + if (frame.length < 2 || frame[0] != respCodeOffbandDeviceUi) return null; + final sub = frame[1]; + + if (sub == offbandUiErr) { + return OffbandUiReply( + command: respCodeOffbandDeviceUi, + sub: sub, + errorMessage: ButtonConfigError.describe( + frame.length >= 3 ? frame[2] : 0x00, + ), + ); + } + + if (sub == offbandUiMatrixGet) { + if (frame.length < 4) return null; + final supported = frame[2]; + final count = frame[3]; + final assignments = {}; + for (var i = 0; i < count; i++) { + final base = 4 + (i * 2); + // Stop at the real end of the frame rather than trusting the count byte. + if (base + 1 >= frame.length) break; + final seq = ButtonSequence.fromCode(frame[base]); + final action = ButtonAction.fromCode(frame[base + 1]); + // An unknown sequence or action from newer firmware is skipped rather + // than guessed at; the rows the client does understand still render. + if (seq != null && action != null) assignments[seq] = action; + } + return OffbandUiReply( + command: respCodeOffbandDeviceUi, + sub: sub, + matrix: ButtonMatrix( + assignments: assignments, + supportedActions: supported, + ), + ); + } + + if (sub == offbandUiMatrixSet) { + if (frame.length < 4) return null; + return OffbandUiReply( + command: respCodeOffbandDeviceUi, + sub: sub, + setSequence: ButtonSequence.fromCode(frame[2]), + setAction: ButtonAction.fromCode(frame[3]), + ); + } + + return null; +} + +/// Parse a `0xC6` notification-scope reply. Null if this is not one. +OffbandUiReply? parseNotifyScopeReply(Uint8List frame) { + if (frame.length < 2 || frame[0] != respCodeOffbandDeviceUi) return null; + final sub = frame[1]; + + // The shared 0x7F error sub-code is owned by parseButtonMatrixReply, which + // the dispatcher tries first, so it is deliberately not handled twice here. + if (sub != offbandUiScopeGet && sub != offbandUiScopeSet) return null; + if (frame.length < 3) return null; + final scope = DeviceNotifyScope.fromCode(frame[2]); + if (scope == null) { + // A scope the client does not know is an error the user can see, not a + // silent fallback to some default that would misreport the device. + return OffbandUiReply( + command: respCodeOffbandDeviceUi, + sub: sub, + errorMessage: + 'The radio reported an unknown notification scope ' + '(0x${frame[2].toRadixString(16).padLeft(2, '0')})', + ); + } + return OffbandUiReply( + command: respCodeOffbandDeviceUi, + sub: sub, + scope: scope, + ); +} diff --git a/lib/screens/settings/device_ui_view.dart b/lib/screens/settings/device_ui_view.dart new file mode 100644 index 0000000..b1b8577 --- /dev/null +++ b/lib/screens/settings/device_ui_view.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../connector/meshcore_connector.dart'; +import '../../connector/offband_device_ui.dart'; + +/// Settings for a headless device's physical UI: the button-action matrix +/// (#474) and the device notification scope (#475). +/// +/// The whole pane is capability-gated. A radio that does not advertise the bit +/// gets no screen and never sees the command, so stock and older firmware +/// degrade silently with nothing shown and no error. +/// +/// English-only for now, matching the serial-capture pane (#430); localization +/// is a follow-up rather than a blocker on shipping the capability. +class DeviceUiView extends StatefulWidget { + const DeviceUiView({super.key}); + + @override + State createState() => _DeviceUiViewState(); +} + +class _DeviceUiViewState extends State { + @override + void initState() { + super.initState(); + // Re-read on open as well as on device-info: the user may have changed the + // scope by triple-pressing the device since the last refresh. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final c = context.read(); + c.requestButtonMatrix(); + c.requestNotifyScope(); + }); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, connector, _) { + final showButtons = connector.supportsButtonMatrix; + final showScope = connector.supportsNotifyScope; + // A radio that advertises nothing gets a diagnosis, not a blank screen. + // Silence used to be indistinguishable from a broken client, which is + // exactly the failure this pane is meant to make visible. + if (!showButtons && !showScope) { + final caps2 = connector.offbandCaps2; + return ListView( + children: [ + const _SectionHeader('Not advertised by this radio'), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + caps2 == null + ? 'This radio sends no capability byte 2 at all, which ' + 'means its firmware predates the feature. Nothing ' + 'is wrong with the app; the radio needs newer ' + 'firmware.' + : 'This radio sends capability byte 2 as 0x' + '${caps2.toRadixString(16).padLeft(2, '0')}, with ' + 'neither the notification-scope bit (0x01) nor the ' + 'button-matrix bit (0x02) set. Its firmware knows ' + 'about byte 2 but does not claim these features, ' + 'for example a board with no buzzer.', + ), + ), + ListTile( + leading: const Icon(Icons.memory_outlined), + title: const Text('Capability byte 2'), + subtitle: Text( + caps2 == null + ? 'absent (frame shorter than 85 bytes)' + : '0x${caps2.toRadixString(16).padLeft(2, '0')}', + ), + ), + const ListTile( + leading: Icon(Icons.block_outlined), + title: Text('No command will be sent'), + subtitle: Text( + 'The app never emits this command to a radio that has not ' + 'advertised support for it.', + ), + ), + ], + ); + } + // The radio advertises the capability but shipped firmware has no + // get/set command yet, so it can be detected and not queried. Say that + // plainly instead of spinning forever on a read that never returns. + if (!connector.supportsDeviceUiCommand) { + return ListView( + children: [ + const _SectionHeader('Supported by this radio'), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + 'This radio reports that it supports ' + '${showScope && showButtons + ? 'notification scope and button actions' + : showScope + ? 'the notification scope' + : 'button actions'}.\n\n' + 'Reading and changing it from the app needs a firmware ' + 'update that is still in progress, so there is nothing to ' + 'set here yet.', + ), + ), + if (showScope) + const ListTile( + leading: Icon(Icons.touch_app_outlined), + title: Text('Change it on the device'), + subtitle: Text( + 'Triple-press the button to cycle All, Self, then None. ' + 'The default is All.', + ), + ), + ], + ); + } + return ListView( + children: [ + if (connector.deviceUiError != null) + _ErrorBanner( + message: connector.deviceUiError!, + onDismiss: connector.clearDeviceUiError, + ), + if (showScope) ...[ + const _SectionHeader('Device notification scope'), + const Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + 'Controls whether this radio\'s buzzer sounds. This is ' + 'separate from per-channel notifications, which control ' + 'whether this app notifies you.', + ), + ), + ..._scopeTiles(connector), + const Divider(height: 24), + ], + if (showButtons) ...[ + const _SectionHeader('Button actions'), + const Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + 'Assign what each button press does. Long press is reserved ' + 'by the firmware for CLI rescue and power off, so it cannot ' + 'be reassigned.', + ), + ), + ..._buttonTiles(connector), + ], + ], + ); + }, + ); + } + + List _scopeTiles(MeshCoreConnector connector) { + final current = connector.deviceNotifyScope; + if (current == null) { + return [ + const ListTile( + leading: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + title: Text('Reading scope from the radio'), + ), + ]; + } + // Plain ListTiles rather than RadioListTile: the Radio group API is + // deprecated in this Flutter version and the replacement needs a + // RadioGroup ancestor, which buys nothing for three mutually exclusive + // rows. + return DeviceNotifyScope.values + .map( + (scope) => ListTile( + leading: Icon( + scope == current + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: scope == current + ? Theme.of(context).colorScheme.primary + : null, + ), + title: Text(scope.label), + subtitle: Text(scope.description), + selected: scope == current, + onTap: () => connector.setNotifyScope(scope), + ), + ) + .toList(); + } + + List _buttonTiles(MeshCoreConnector connector) { + final matrix = connector.buttonMatrix; + if (matrix == null) { + return [ + const ListTile( + leading: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + title: Text('Reading button configuration from the radio'), + ), + ]; + } + // Only actions the radio said it can perform are offered, so a board with + // no buzzer or no GPS never shows a choice it would reject. (#474) + final actions = matrix.availableActions; + return ButtonSequence.values.map((seq) { + final assigned = matrix.assignments[seq] ?? ButtonAction.none; + return ListTile( + title: Text(seq.label), + subtitle: Text(assigned.label), + trailing: DropdownButton( + value: actions.contains(assigned) ? assigned : ButtonAction.none, + onChanged: (value) { + if (value != null) connector.setButtonAction(seq, value); + }, + items: actions + .map( + (a) => DropdownMenuItem( + value: a, + child: Text(a.label), + ), + ) + .toList(), + ), + ); + }).toList(); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.title); + final String title; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text(title, style: Theme.of(context).textTheme.titleMedium), + ); +} + +/// Persistent error banner. Stays until dismissed rather than flashing, per the +/// error-visibility rule: an error the user cannot finish reading is not a +/// surfaced error. +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.message, required this.onDismiss}); + final String message; + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: scheme.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: TextStyle(color: scheme.onErrorContainer), + ), + ), + IconButton( + icon: Icon(Icons.close, color: scheme.onErrorContainer), + onPressed: onDismiss, + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4d173e7..4cea205 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -21,6 +21,7 @@ import 'settings/app_settings_view.dart'; import 'settings/message_settings_view.dart'; import 'settings/observer_settings_view.dart'; import 'settings/blocked_view.dart'; +import 'settings/device_ui_view.dart'; import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; import 'serial_capture_screen.dart'; @@ -268,6 +269,34 @@ class _SettingsScreenState extends State { ), ), ], + // Button and buzzer: device UI config (#474/#475). Owner-placed + // under Node Settings, appended last so nothing already here + // moves. + if (connector.isConnected) ...[ + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.radio_button_checked_outlined), + title: const Text('Button and buzzer'), + subtitle: const Text( + 'Assign button actions and set when this radio beeps', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar( + title: const Text('Button and buzzer'), + centerTitle: true, + ), + body: const DeviceUiView(), + ), + ), + ); + }, + ), + ], ], ), ), diff --git a/test/connector/offband_device_ui_test.dart b/test/connector/offband_device_ui_test.dart new file mode 100644 index 0000000..4d2ccb2 --- /dev/null +++ b/test/connector/offband_device_ui_test.dart @@ -0,0 +1,213 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/connector/offband_device_ui.dart'; + +void main() { + group('capability gating on caps byte 2 (#474/#475)', () { + test('requires the explicit bit', () { + expect(firmwareSupportsButtonMatrix(offbandCap2ButtonMatrix), isTrue); + expect(firmwareSupportsNotifyScope(offbandCap2NotifyScope), isTrue); + }); + + test('false when the other feature bit is set but not this one', () { + expect(firmwareSupportsButtonMatrix(offbandCap2NotifyScope), isFalse); + expect(firmwareSupportsNotifyScope(offbandCap2ButtonMatrix), isFalse); + }); + + test('absent byte 2 is unsupported, not an error', () { + expect(firmwareSupportsButtonMatrix(null), isFalse); + expect(firmwareSupportsNotifyScope(null), isFalse); + expect(firmwareSupportsButtonMatrix(0x00), isFalse); + }); + + test('the two bits do not collide', () { + expect(offbandCap2ButtonMatrix & offbandCap2NotifyScope, equals(0)); + }); + }); + + group('request frames', () { + test('command bytes do not collide with the shipped fork commands', () { + final used = { + cmdOffbandGps, + cmdOffbandBlock, + cmdOffbandFemLna, + cmdOffbandCaplog, + }; + expect(used.contains(cmdOffbandDeviceUi), isFalse); + }); + + test('matrix get and set encode as documented', () { + expect(buildButtonMatrixGetFrame(), equals([0xC5, 0x03])); + expect( + buildButtonMatrixSetFrame( + ButtonSequence.double, + ButtonAction.sendAdvert, + ), + equals([0xC5, 0x04, 0x01, 0x01]), + ); + }); + + test('scope get and set encode as documented', () { + expect(buildNotifyScopeGetFrame(), equals([0xC5, 0x01])); + expect( + buildNotifyScopeSetFrame(DeviceNotifyScope.self), + equals([0xC5, 0x02, 0x01]), + ); + }); + }); + + group('button matrix GET parse', () { + test('reads the supported mask and the assignment rows', () { + // supported = advert | gps, two rows: single->none, double->advert + final mask = ButtonAction.sendAdvert.mask | ButtonAction.toggleGps.mask; + final reply = parseButtonMatrixReply( + Uint8List.fromList([0xC5, 0x03, mask, 2, 0x00, 0x00, 0x01, 0x01]), + ); + expect(reply, isNotNull); + expect(reply!.isError, isFalse); + final m = reply.matrix!; + expect(m.assignments[ButtonSequence.single], ButtonAction.none); + expect(m.assignments[ButtonSequence.double], ButtonAction.sendAdvert); + expect(m.supports(ButtonAction.sendAdvert), isTrue); + expect(m.supports(ButtonAction.toggleGps), isTrue); + expect(m.supports(ButtonAction.batteryBeep), isFalse); + }); + + test('unassigned is always offered so a sequence can be cleared', () { + final m = ButtonMatrix(assignments: const {}, supportedActions: 0); + expect(m.supports(ButtonAction.none), isTrue); + expect(m.availableActions, contains(ButtonAction.none)); + }); + + test('a lying count byte cannot read past the frame', () { + // count says 5 rows, only one is present. + final reply = parseButtonMatrixReply( + Uint8List.fromList([0xC5, 0x03, 0xFF, 5, 0x00, 0x01]), + ); + expect(reply, isNotNull); + expect(reply!.matrix!.assignments.length, equals(1)); + }); + + test('unknown sequence or action codes are skipped, not guessed', () { + final reply = parseButtonMatrixReply( + Uint8List.fromList([0xC5, 0x03, 0xFF, 2, 0x7E, 0x01, 0x01, 0x7E]), + ); + expect(reply!.matrix!.assignments, isEmpty); + }); + + test('truncated GET yields null rather than throwing', () { + for (final f in [ + [0xC5], + [0xC5, 0x03], + [0xC5, 0x03, 0x00], + ]) { + expect(parseButtonMatrixReply(Uint8List.fromList(f)), isNull); + } + }); + + test('a foreign command byte is not claimed', () { + expect( + parseButtonMatrixReply(Uint8List.fromList([0xC3, 0x01, 0x00])), + isNull, + ); + expect( + parseNotifyScopeReply(Uint8List.fromList([0xC3, 0x01, 0x00])), + isNull, + ); + }); + }); + + group('error replies carry the device reason', () { + test('known reason codes map to their message', () { + final reply = parseButtonMatrixReply( + Uint8List.fromList([0xC5, 0x7F, 0x03]), + ); + expect(reply!.isError, isTrue); + // Firmware-owned reason codes (FuchsiaCreek, 2026-08-01): 1 unsupported + // action, 2 unknown sequence, 3 no buzzer, 4 no GPS, 5 malformed. + expect(reply.errorMessage, ButtonConfigError.noBuzzer.message); + expect(ButtonConfigError.noBuzzer.code, equals(0x03)); + expect(ButtonConfigError.unknownSequence.code, equals(0x02)); + expect(ButtonConfigError.noGps.code, equals(0x04)); + expect(ButtonConfigError.malformed.code, equals(0x05)); + }); + + test( + 'an unknown reason is surfaced with its raw code, never swallowed', + () { + final reply = parseButtonMatrixReply( + Uint8List.fromList([0xC5, 0x7F, 0x5A]), + ); + expect(reply!.isError, isTrue); + expect(reply.errorMessage, contains('0x5a')); + }, + ); + + test('the shared error sub-code is owned by one parser, not both', () { + // Both surfaces ride 0xC5, so 0x7F must be handled exactly once or an + // error would be processed twice by the dispatcher. + final frame = Uint8List.fromList([0xC5, 0x7F, 0x04]); + expect(parseButtonMatrixReply(frame)!.isError, isTrue); + expect(parseNotifyScopeReply(frame), isNull); + }); + }); + + group('notification scope parse', () { + test('reads each scope', () { + for (final s in DeviceNotifyScope.values) { + final reply = parseNotifyScopeReply( + Uint8List.fromList([0xC5, 0x01, s.code]), + ); + expect(reply!.scope, equals(s)); + } + }); + + test('an unknown scope is an error, not a silent default', () { + final reply = parseNotifyScopeReply( + Uint8List.fromList([0xC5, 0x01, 0x40]), + ); + expect(reply!.isError, isTrue); + expect(reply.scope, isNull); + expect(reply.errorMessage, contains('0x40')); + }); + + test('truncated scope reply yields null', () { + expect(parseNotifyScopeReply(Uint8List.fromList([0xC5, 0x01])), isNull); + }); + }); + + group('SET echo', () { + test('confirmed assignment folds into the held matrix', () { + final reply = parseButtonMatrixReply( + Uint8List.fromList([0xC5, 0x04, 0x02, 0x03]), + ); + expect(reply!.setSequence, ButtonSequence.triple); + expect(reply.setAction, ButtonAction.cycleNotifyScope); + + final before = ButtonMatrix( + assignments: const {ButtonSequence.triple: ButtonAction.none}, + supportedActions: 0xFF, + ); + final after = before.withAssignment(reply.setSequence!, reply.setAction!); + expect( + after.assignments[ButtonSequence.triple], + ButtonAction.cycleNotifyScope, + ); + expect(after.supportedActions, equals(0xFF)); + }); + }); + + group('long press is not assignable', () { + test('no sequence models a long press', () { + // Firmware reserves long press for CLI rescue and power off. Exposing it + // would let a user lock themselves out of a screenless device. + expect(ButtonSequence.values.length, equals(4)); + expect( + ButtonSequence.values.map((s) => s.label).join(' ').toLowerCase(), + isNot(contains('long')), + ); + }); + }); +}