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)
pull/502/head
Strycher 2 months ago
parent 7aee4007b3
commit 4b337be586

@ -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<void> 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<void> 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<void> 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<void> 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);

@ -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 &&

@ -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<ButtonSequence, ButtonAction> 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<ButtonAction> 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 = <ButtonSequence, ButtonAction>{};
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,
);
}

@ -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<DeviceUiView> createState() => _DeviceUiViewState();
}
class _DeviceUiViewState extends State<DeviceUiView> {
@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<MeshCoreConnector>();
c.requestButtonMatrix();
c.requestNotifyScope();
});
}
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
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<Widget> _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<Widget> _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<ButtonAction>(
value: actions.contains(assigned) ? assigned : ButtonAction.none,
onChanged: (value) {
if (value != null) connector.setButtonAction(seq, value);
},
items: actions
.map(
(a) => DropdownMenuItem<ButtonAction>(
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,
),
],
),
);
}
}

@ -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<SettingsScreen> {
),
),
],
// 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(),
),
),
);
},
),
],
],
),
),

@ -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 [
<int>[0xC5],
<int>[0xC5, 0x03],
<int>[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')),
);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.