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)feat/483-button-buzzer-ui
parent
7aee4007b3
commit
aeef31cee3
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in new issue