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
parent
7aee4007b3
commit
4b337be586
@ -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