Every control is the SCREEN_GET reply; each change sends its SET and the controls redraw from the 14-byte re-read reply, never from the tap.
"Menus and lists" is the badge's Nav text, "Messages" its Message text (owner decision 4).
Screen off offers exactly the five steps firmware accepts. When scr_stored is 0 (compiled default), no segment is selected and the line reads "Board default: 1 m" from scr_secs; picking a step replaces it and there is no way back to 0, on the badge or here.
-
9Time zone
The badge's 45 zones, its "not set", and a suggestion: the badge suggests from GPS, the app from the phone.
+
9Time zone
The badge's 40 zones, its "not set", and a suggestion: the badge suggests from GPS, the app from the phone.
Time zone
Search zones
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index 911fffab..04b4d6fe 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -66,6 +66,8 @@ import 'meshcore_protocol.dart';
import '../helpers/region_key.dart';
import '../storage/channel_region_scope_store.dart';
import 'offband_device_ui.dart';
+import 'offband_screen_settings.dart';
+import 'offband_tones.dart';
import 'caplog_reassembler.dart';
class DirectRepeater {
@@ -413,6 +415,11 @@ class MeshCoreConnector extends ChangeNotifier {
DeviceUiSurface? _pendingDeviceUiWrite;
GpsModeState? _gpsModeState;
final Map _channelTunes = {};
+ final Map _eventTones = {};
+ final Map _tuneSlots = {};
+ ScreenSettingsState? _screenSettings;
+ bool _tuneSlotTransferActive = false;
+ final List<_DeviceUiWaiter> _deviceUiWaiters = [];
int _pathHashByteWidth = 1;
CompanionRadioStats? _latestRadioStats;
Stopwatch? _airtimeBumpStopwatch;
@@ -903,12 +910,30 @@ class MeshCoreConnector extends ChangeNotifier {
/// Last GPS state the radio reported, or null if not read yet.
GpsModeState? get gpsModeState => _gpsModeState;
- /// Whether this radio answers the `0xC5` channel-tune sub-codes (#971).
- bool get supportsChannelTune => firmwareSupportsChannelTune(_offbandCaps2);
+ /// Whether this radio answers the `0xC5` tune sub-codes: custom slots,
+ /// channel tune, event tones and preview (caps byte 2 `0x10`).
+ bool get supportsTunes => firmwareSupportsTunes(_offbandCaps2);
+
+ /// Whether this radio answers the screen-settings sub-codes (#987).
+ bool get supportsScreenSettings =>
+ firmwareSupportsScreenSettings(_offbandCaps2);
/// The tune the radio reported for [channel], or null if not read yet.
ChannelTune? channelTuneFor(int channel) => _channelTunes[channel];
+ /// The tune the radio reported for [event], or null if not read yet.
+ EventToneState? eventTone(ToneEvent event) => _eventTones[event];
+
+ /// `SLOT_GET` replies read so far, by slot index.
+ Map get tuneSlots => Map.unmodifiable(_tuneSlots);
+
+ /// The radio's screen and battery settings, or null if not read yet.
+ ScreenSettingsState? get screenSettings => _screenSettings;
+
+ /// True while a slot is being read or saved. One at a time: firmware takes
+ /// one push per slot, and a second would displace the first.
+ bool get tuneSlotTransferActive => _tuneSlotTransferActive;
+
/// Which surface [deviceUiError] belongs to, so a screen shows only its own.
/// Null when the error could not be attributed to an outstanding write.
DeviceUiSurface? get deviceUiErrorSurface => _deviceUiErrorSurface;
@@ -1052,7 +1077,7 @@ class MeshCoreConnector extends ChangeNotifier {
/// Ask the radio which tune [channel] plays (#971).
Future requestChannelTune(int channel) async {
- if (!_deviceUiGateOpen('chtune GET', advertised: supportsChannelTune)) {
+ if (!_deviceUiGateOpen('chtune GET', advertised: supportsTunes)) {
return;
}
_appDebugLogService?.info(
@@ -1065,7 +1090,7 @@ class MeshCoreConnector extends ChangeNotifier {
/// Set which tune [channel] plays. Local state follows the device's re-read
/// reply, never the request, so a refused choice is never shown as saved.
Future setChannelTune(int channel, ChannelTune tune) async {
- if (!_deviceUiGateOpen('chtune SET', advertised: supportsChannelTune)) {
+ if (!_deviceUiGateOpen('chtune SET', advertised: supportsTunes)) {
return;
}
_deviceUiError = null;
@@ -1124,6 +1149,283 @@ class MeshCoreConnector extends ChangeNotifier {
await sendFrame(buildGpsModeSetFrame(mode, interval));
}
+ /// Send [frame] and wait for the reply [matches] accepts, or for a refusal
+ /// attributed to [surface]. Null on timeout.
+ ///
+ /// The multi-frame flows (slot read, slot save) and preview need the answer
+ /// before their next step; the single-frame settings do not, and keep the
+ /// fire-and-forget shape the rest of this command uses.
+ Future _sendDeviceUiAndAwait(
+ Uint8List frame,
+ DeviceUiSurface surface,
+ bool Function(OffbandUiReply reply) matches, {
+ Duration timeout = const Duration(seconds: 4),
+ }) async {
+ final waiter = _DeviceUiWaiter(surface, matches);
+ _deviceUiWaiters.add(waiter);
+ _pendingDeviceUiWrite = surface;
+ try {
+ await sendFrame(frame);
+ return await waiter.completer.future.timeout(
+ timeout,
+ onTimeout: () => null,
+ );
+ } finally {
+ _deviceUiWaiters.remove(waiter);
+ // Answered or not, this request is no longer outstanding, so a later
+ // refusal must not be pinned on it.
+ if (_pendingDeviceUiWrite == surface) _pendingDeviceUiWrite = null;
+ }
+ }
+
+ /// Hand [reply] to the first waiter that wants it. A refusal carries no
+ /// sub-code, so it goes to the waiter on the surface it was attributed to.
+ void _offerDeviceUiReply(OffbandUiReply reply, {DeviceUiSurface? errorOn}) {
+ for (final w in _deviceUiWaiters) {
+ final wanted = reply.isError
+ ? errorOn != null && w.surface == errorOn
+ : w.matches(reply);
+ if (wanted && !w.completer.isCompleted) {
+ w.completer.complete(reply);
+ return;
+ }
+ }
+ }
+
+ void _setDeviceUiError(DeviceUiSurface surface, String message) {
+ _deviceUiErrorSurface = surface;
+ _deviceUiError = message;
+ _appDebugLogService?.warn('${surface.name}: $message', tag: 'DeviceUI');
+ notifyListeners();
+ }
+
+ /// Read all six event tones (#986).
+ Future requestEventTones() async {
+ if (!_deviceUiGateOpen('evtone GET', advertised: supportsTunes)) return;
+ for (final event in ToneEvent.values) {
+ await sendFrame(buildEventToneGetFrame(event));
+ }
+ }
+
+ /// Set the tune [event] plays. The id must be one the picker offers; the
+ /// radio refuses the rest, and the row changes only on its re-read reply.
+ Future setEventTone(ToneEvent event, int tuneId) async {
+ if (!_deviceUiGateOpen('evtone SET', advertised: supportsTunes)) return;
+ _deviceUiError = null;
+ _deviceUiErrorSurface = null;
+ _pendingDeviceUiWrite = DeviceUiSurface.eventTone;
+ _appDebugLogService?.info(
+ 'evtone SET -> ${event.label} = $tuneId [0xC5][0x10]',
+ tag: 'DeviceUI',
+ );
+ await sendFrame(buildEventToneSetFrame(event, tuneId));
+ }
+
+ /// Audition [tuneId] on the radio. Null when the radio did not answer, or
+ /// refused the id (the refusal is then shown like any other).
+ Future playTune(int tuneId) async {
+ if (!_deviceUiGateOpen('play', advertised: supportsTunes)) return null;
+ _appDebugLogService?.info('play -> $tuneId [0xC5][0x11]', tag: 'DeviceUI');
+ final reply = await _sendDeviceUiAndAwait(
+ buildTunePlayFrame(tuneId),
+ DeviceUiSurface.tunePlay,
+ (r) => r.tunePlay?.tuneId == tuneId,
+ );
+ final result = reply?.tunePlay?.result;
+ _appDebugLogService?.info(
+ 'play reply: ${result?.name ?? (reply == null ? 'no answer' : 'refused')}',
+ tag: 'DeviceUI',
+ );
+ return result;
+ }
+
+ /// Read every slot's name and length (#970).
+ Future requestTuneSlots() async {
+ if (!_deviceUiGateOpen('slot GET', advertised: supportsTunes)) return;
+ for (var slot = 0; slot < tuneSlotCount; slot++) {
+ await sendFrame(buildSlotGetFrame(slot));
+ }
+ }
+
+ /// A slot's whole stored tune, read in chunks, or null if the read failed.
+ /// [onProgress] reports bytes read so far.
+ Future readTuneSlot(
+ int slot, {
+ void Function(int bytesRead)? onProgress,
+ }) async {
+ if (!_deviceUiGateOpen('slot READ', advertised: supportsTunes)) {
+ return null;
+ }
+ if (_tuneSlotTransferActive) return null;
+ _tuneSlotTransferActive = true;
+ notifyListeners();
+ try {
+ final out = [];
+ while (true) {
+ final offset = out.length;
+ final reply = await _sendDeviceUiAndAwait(
+ buildSlotReadFrame(slot, offset),
+ DeviceUiSurface.tuneSlot,
+ (r) => r.slotChunk?.slot == slot && r.slotChunk?.offset == offset,
+ );
+ final chunk = reply?.slotChunk;
+ if (chunk == null) {
+ if (reply == null) {
+ _setDeviceUiError(
+ DeviceUiSurface.tuneSlot,
+ 'The radio did not answer while reading this tune.',
+ );
+ }
+ return null;
+ }
+ out.addAll(chunk.bytes);
+ onProgress?.call(out.length);
+ if (!chunk.more) break;
+ // A chunk that says more but carries nothing would loop forever.
+ if (chunk.bytes.isEmpty || out.length > maxSlotRtttl) {
+ _setDeviceUiError(
+ DeviceUiSurface.tuneSlot,
+ 'The radio sent this tune in a shape the app cannot read.',
+ );
+ return null;
+ }
+ }
+ _appDebugLogService?.info(
+ 'slot READ: slot=$slot ${out.length} bytes',
+ tag: 'DeviceUI',
+ );
+ return latin1.decode(out);
+ } finally {
+ _tuneSlotTransferActive = false;
+ notifyListeners();
+ }
+ }
+
+ /// Save [rtttl] to [slot] in chunks sized to the transport, then re-read the
+ /// slot. An empty [rtttl] clears it. True only when the radio committed it.
+ ///
+ /// The radio replaces a slot only on the final chunk, so a push that fails
+ /// partway leaves the old tune intact. A displaced push (`ERR_SLOT_OFFSET`)
+ /// restarts from 0 once; a second refusal stops and is shown.
+ Future saveTuneSlot(
+ int slot,
+ String rtttl, {
+ void Function(int bytesSent, int total)? onProgress,
+ }) async {
+ if (!_deviceUiGateOpen('slot SET', advertised: supportsTunes)) {
+ return false;
+ }
+ if (_tuneSlotTransferActive) return false;
+ final bytes = latin1.encode(rtttl);
+ if (bytes.length > maxSlotRtttl) {
+ _setDeviceUiError(
+ DeviceUiSurface.tuneSlot,
+ 'A tune can be at most $maxSlotRtttl characters.',
+ );
+ return false;
+ }
+ _deviceUiError = null;
+ _deviceUiErrorSurface = null;
+ _tuneSlotTransferActive = true;
+ notifyListeners();
+ try {
+ final chunkSize = math.max(1, effectiveMaxFrameSize - 5);
+ for (var attempt = 0; attempt < 2; attempt++) {
+ final outcome = await _pushTuneSlot(slot, bytes, chunkSize, onProgress);
+ if (outcome == _SlotPush.committed) {
+ _deviceUiError = null;
+ _deviceUiErrorSurface = null;
+ await _sendDeviceUiAndAwait(
+ buildSlotGetFrame(slot),
+ DeviceUiSurface.tuneSlot,
+ (r) => r.tuneSlot?.slot == slot,
+ );
+ _appDebugLogService?.info(
+ 'slot SET committed: slot=$slot ${bytes.length} bytes',
+ tag: 'DeviceUI',
+ );
+ return true;
+ }
+ if (outcome != _SlotPush.displaced) return false;
+ }
+ return false;
+ } finally {
+ _tuneSlotTransferActive = false;
+ notifyListeners();
+ }
+ }
+
+ Future<_SlotPush> _pushTuneSlot(
+ int slot,
+ List bytes,
+ int chunkSize,
+ void Function(int bytesSent, int total)? onProgress,
+ ) async {
+ var offset = 0;
+ do {
+ final end = math.min(offset + chunkSize, bytes.length);
+ final more = end < bytes.length;
+ final reply = await _sendDeviceUiAndAwait(
+ buildSlotSetFrame(slot, offset, more, bytes.sublist(offset, end)),
+ DeviceUiSurface.tuneSlot,
+ (r) => r.slotAccepted?.slot == slot,
+ );
+ if (reply == null) {
+ _setDeviceUiError(
+ DeviceUiSurface.tuneSlot,
+ 'The radio did not answer while saving this tune. Nothing was '
+ 'changed.',
+ );
+ return _SlotPush.failed;
+ }
+ if (reply.isError) {
+ return reply.errorCode == ButtonConfigError.slotOffset.code
+ ? _SlotPush.displaced
+ : _SlotPush.failed;
+ }
+ // Mid-push the radio counts what it holds; anything but the end of this
+ // chunk means another push took over.
+ if (more && reply.slotAccepted!.accepted != end) {
+ return _SlotPush.displaced;
+ }
+ offset = end;
+ onProgress?.call(offset, bytes.length);
+ } while (offset < bytes.length);
+ return _SlotPush.committed;
+ }
+
+ /// Read the badge's screen and battery settings (#987).
+ Future requestScreenSettings() async {
+ if (!_deviceUiGateOpen('screen GET', advertised: supportsScreenSettings)) {
+ return;
+ }
+ await sendFrame(buildScreenSettingsGetFrame());
+ }
+
+ Future _sendScreenSet(String what, Uint8List frame) async {
+ if (!_deviceUiGateOpen(what, advertised: supportsScreenSettings)) return;
+ _deviceUiError = null;
+ _deviceUiErrorSurface = null;
+ _pendingDeviceUiWrite = DeviceUiSurface.screenSettings;
+ _appDebugLogService?.info('$what -> $frame', tag: 'DeviceUI');
+ await sendFrame(frame);
+ }
+
+ Future setTextSize(BadgeTextTarget target, BadgeTextSize size) =>
+ _sendScreenSet('text SET', buildTextSizeSetFrame(target, size));
+
+ /// [seconds] must be one of [screenOffSteps]; the radio refuses the rest.
+ Future setScreenOff(int seconds) =>
+ _sendScreenSet('scroff SET', buildScreenOffSetFrame(seconds));
+
+ Future setTimeZone(int index) =>
+ _sendScreenSet('tz SET', buildTimeZoneSetFrame(index));
+
+ /// Pin the radio's own current reading as 100%, or hand the full point back
+ /// to auto-learn. The radio refuses a reading that is not a full cell.
+ Future setBatteryFull({required bool pin}) =>
+ _sendScreenSet('batt SET', buildBatteryFullSetFrame(pin: pin));
+
/// 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)
@@ -1141,12 +1443,17 @@ class MeshCoreConnector extends ChangeNotifier {
_pendingDeviceUiWrite = null;
_gpsModeState = null;
_channelTunes.clear();
+ _eventTones.clear();
+ _tuneSlots.clear();
+ _screenSettings = null;
}
void _reconcileDeviceUi() {
if (supportsButtonMatrix) requestButtonMatrix();
if (supportsNotifyScope) requestNotifyScope();
if (supportsGpsMode) requestGpsMode();
+ // Node Settings names the battery full point in its Battery row.
+ if (supportsScreenSettings) requestScreenSettings();
}
/// Single entry point for `0xC5`: both surfaces and the shared error sub-code
@@ -1176,6 +1483,16 @@ class MeshCoreConnector extends ChangeNotifier {
_handleChannelTuneReply(tune);
return;
}
+ final tunes = parseTuneReply(frame);
+ if (tunes != null) {
+ _handleTuneReply(tunes);
+ return;
+ }
+ final screen = parseScreenSettingsReply(frame);
+ if (screen != null) {
+ _handleScreenSettingsReply(screen);
+ return;
+ }
_appDebugLogService?.warn(
'0xC5 reply not understood (sub=0x'
'${frame.length > 1 ? frame[1].toRadixString(16).padLeft(2, '0') : '??'}, '
@@ -1200,10 +1517,59 @@ class MeshCoreConnector extends ChangeNotifier {
DeviceUiSurface.notifyScope => 'Notification scope',
DeviceUiSurface.gpsMode => 'GPS mode',
DeviceUiSurface.channelTune => 'Channel tune',
+ DeviceUiSurface.eventTone => 'Event tone',
+ DeviceUiSurface.tunePlay => 'Tune preview',
+ DeviceUiSurface.tuneSlot => 'Tune slot',
+ DeviceUiSurface.screenSettings => 'Screen settings',
null => 'Device UI request',
};
_appDebugLogService?.warn(
- '$what refused: $_deviceUiError',
+ '$what refused: $_deviceUiError'
+ '${reply.errorDetail == null ? '' : ' (detail ${reply.errorDetail})'}',
+ tag: 'DeviceUI',
+ );
+ _offerDeviceUiReply(reply, errorOn: surface);
+ notifyListeners();
+ }
+
+ void _handleTuneReply(OffbandUiReply reply) {
+ if (reply.isError) {
+ _setDeviceUiError(DeviceUiSurface.eventTone, reply.errorMessage!);
+ return;
+ }
+ if (_pendingDeviceUiWrite == DeviceUiSurface.eventTone &&
+ reply.sub == offbandUiEventToneSet) {
+ _pendingDeviceUiWrite = null;
+ }
+ final ev = reply.eventTone;
+ if (ev != null) {
+ _eventTones[ev.event] = ev.state;
+ _appDebugLogService?.info(
+ 'evtone reply: ${ev.event.label} stored=${ev.state.stored} '
+ 'effective=${ev.state.effective}',
+ tag: 'DeviceUI',
+ );
+ }
+ final slot = reply.tuneSlot;
+ if (slot != null && slot.slot >= 0 && slot.slot < tuneSlotCount) {
+ _tuneSlots[slot.slot] = slot.info;
+ }
+ _offerDeviceUiReply(reply);
+ notifyListeners();
+ }
+
+ void _handleScreenSettingsReply(OffbandUiReply reply) {
+ if (_pendingDeviceUiWrite == DeviceUiSurface.screenSettings &&
+ reply.sub != offbandUiScreenGet) {
+ _pendingDeviceUiWrite = null;
+ }
+ final s = reply.screen!;
+ _screenSettings = s;
+ _appDebugLogService?.info(
+ 'screen reply: nav=${s.navText?.name} msg=${s.messageText?.name} '
+ 'scroff=${s.screenOffStored}/${s.screenOffSeconds}s tz=${s.timeZone} '
+ 'batt=${s.batteryFullStored}/${s.batteryFullMv}mV '
+ 'pinned=${s.batteryPinned}',
tag: 'DeviceUI',
);
notifyListeners();
@@ -10357,3 +10723,14 @@ class _PendingCommandAck {
_PendingCommandAck({required this.commandCode, this.channelSendQueueId});
}
+
+/// A `0xC5` request waiting for its reply.
+class _DeviceUiWaiter {
+ _DeviceUiWaiter(this.surface, this.matches);
+ final DeviceUiSurface surface;
+ final bool Function(OffbandUiReply reply) matches;
+ final Completer completer = Completer();
+}
+
+/// How one pass of a slot push ended.
+enum _SlotPush { committed, displaced, failed }
diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart
index 6dfe7c36..c8526bf2 100644
--- a/lib/connector/meshcore_protocol.dart
+++ b/lib/connector/meshcore_protocol.dart
@@ -602,9 +602,12 @@ bool firmwareSupportsButtonMatrix(int? offbandCaps2) =>
bool firmwareSupportsNotifyScope(int? offbandCaps2) =>
offbandCaps2 != null && (offbandCaps2 & offbandCap2NotifyScope) != 0;
-/// Per-channel tune over `0xC5` sub-codes 0x0B / 0x0C (#971). Firmware sets it
-/// where `PIN_BUZZER` is defined (meshcore-firmware#1789).
-const int offbandCap2ChannelTune = 0x10;
+/// The tune system over `0xC5` (#970, #971, #986): custom slots (0x09, 0x0A,
+/// 0x12), channel tune (0x0B, 0x0C), event tones (0x0F, 0x10) and preview
+/// (0x11). Firmware sets it where the UI plays every sound through its tune
+/// engine, which needs a buzzer but is not implied by one, so a radio can
+/// advertise the scope bit without this (meshcore-firmware `3e788e2a`).
+const int offbandCap2Tunes = 0x10;
/// GPS mode and Power-save interval over `0xC5` sub-codes 0x0D / 0x0E (#969).
/// Firmware sets it where `ENV_INCLUDE_GPS == 1` (meshcore-firmware#1789).
@@ -614,9 +617,17 @@ const int offbandCap2ChannelTune = 0x10;
/// for that. Never offer Power save from this bit alone.
const int offbandCap2GpsMode = 0x20;
-/// True iff this radio answers the `0xC5` channel-tune sub-codes (#971).
-bool firmwareSupportsChannelTune(int? offbandCaps2) =>
- offbandCaps2 != null && (offbandCaps2 & offbandCap2ChannelTune) != 0;
+/// The badge's own display and battery settings over `0xC5` 0x13 to 0x17
+/// (#987): text sizes, Screen off, time zone, battery full point.
+const int offbandCap2ScreenSettings = 0x40;
+
+/// True iff this radio answers the `0xC5` tune sub-codes.
+bool firmwareSupportsTunes(int? offbandCaps2) =>
+ offbandCaps2 != null && (offbandCaps2 & offbandCap2Tunes) != 0;
+
+/// True iff this radio answers the `0xC5` screen-settings sub-codes (#987).
+bool firmwareSupportsScreenSettings(int? offbandCaps2) =>
+ offbandCaps2 != null && (offbandCaps2 & offbandCap2ScreenSettings) != 0;
/// True iff this radio answers the `0xC5` GPS-mode sub-codes (#969).
bool firmwareSupportsGpsMode(int? offbandCaps2) =>
diff --git a/lib/connector/offband_device_ui.dart b/lib/connector/offband_device_ui.dart
index ab743780..8a5ba6bb 100644
--- a/lib/connector/offband_device_ui.dart
+++ b/lib/connector/offband_device_ui.dart
@@ -1,5 +1,8 @@
import 'dart:typed_data';
+import 'offband_screen_settings.dart';
+import 'offband_tones.dart';
+
/// Headless-device UI configuration: the button-action matrix (#474) and the
/// device notification scope (#475).
///
@@ -83,7 +86,22 @@ enum ButtonConfigError {
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');
+ malformed(0x05, 'The radio could not read the request'),
+ unsupportedIndicator(0x06, 'This radio has no display or LED to set'),
+ slotOffset(
+ 0x07,
+ 'The radio stopped accepting this tune partway through. Nothing was '
+ 'changed. Try saving again.',
+ ),
+ batteryTooHigh(
+ 0x08,
+ 'The reading is higher than a full cell, so it was not used.',
+ ),
+ batteryNotCharged(
+ 0x09,
+ 'Not charged yet. Charge the radio fully, then set 100% again. Nothing '
+ 'was changed.',
+ );
const ButtonConfigError(this.code, this.message);
final int code;
@@ -103,7 +121,16 @@ enum ButtonConfigError {
/// Which `0xC5` surface a write targeted. The shared error frame
/// `[0xC5][0x7F][reason]` names no sub-code, so the client attributes an error
/// to the write it has outstanding, and each screen shows only its own.
-enum DeviceUiSurface { buttonMatrix, notifyScope, gpsMode, channelTune }
+enum DeviceUiSurface {
+ buttonMatrix,
+ notifyScope,
+ gpsMode,
+ channelTune,
+ eventTone,
+ tunePlay,
+ tuneSlot,
+ screenSettings,
+}
/// The refusal reason for a GPS write. Reason 1 means Power save on a build
/// that cannot run it (meshcore-firmware#1789); the rest read as for buttons.
@@ -403,12 +430,22 @@ class OffbandUiReply {
final DeviceNotifyScope? scope;
final GpsModeState? gps;
final ChannelTuneReply? channelTune;
+ final EventToneReply? eventTone;
+ final TuneSlotReply? tuneSlot;
+ final TuneSlotAccepted? slotAccepted;
+ final TuneSlotChunk? slotChunk;
+ final TunePlayReply? tunePlay;
+ final ScreenSettingsState? screen;
final String? errorMessage;
/// The raw reason byte of a `0x7F` error, so a surface can word it for its
/// own context. Null on every other reply.
final int? errorCode;
+ /// The byte after the reason, when the error carries one: `ERR_SLOT_OFFSET`
+ /// sends the offset the radio expects next.
+ final int? errorDetail;
+
const OffbandUiReply({
required this.command,
required this.sub,
@@ -418,8 +455,15 @@ class OffbandUiReply {
this.scope,
this.gps,
this.channelTune,
+ this.eventTone,
+ this.tuneSlot,
+ this.slotAccepted,
+ this.slotChunk,
+ this.tunePlay,
+ this.screen,
this.errorMessage,
this.errorCode,
+ this.errorDetail,
});
bool get isError => errorMessage != null;
@@ -440,6 +484,7 @@ OffbandUiReply? parseButtonMatrixReply(Uint8List frame) {
sub: sub,
errorMessage: ButtonConfigError.describe(code),
errorCode: code,
+ errorDetail: frame.length >= 4 ? frame[3] : null,
);
}
diff --git a/lib/connector/offband_screen_settings.dart b/lib/connector/offband_screen_settings.dart
new file mode 100644
index 00000000..d0e7de8e
--- /dev/null
+++ b/lib/connector/offband_screen_settings.dart
@@ -0,0 +1,150 @@
+import 'dart:typed_data';
+
+import 'offband_device_ui.dart';
+
+/// The badge's own display and battery settings over `0xC5` (#987): both
+/// text sizes, Screen off, time zone and the battery full point. Gated on caps
+/// byte 2 `0x40` SCREEN_SETTINGS (meshcore-firmware `3e788e2a`).
+///
+/// Every reply, GET or SET, is the whole set re-read after the write:
+/// `[0xC5][sub][nav][msg][scr_stored:2][scr_secs:2][tz][batt_stored:2]
+/// [batt_full:2][batt_pinned]`, 14 bytes, all little-endian.
+
+const int offbandUiScreenGet = 0x13;
+const int offbandUiTextSet = 0x14;
+const int offbandUiScreenOffSet = 0x15;
+const int offbandUiTimeZoneSet = 0x16;
+const int offbandUiBatterySet = 0x17;
+const int _screenReplyLength = 14;
+
+/// The three text sizes, in firmware's encoding (`BadgeLayout.h`).
+enum BadgeTextSize {
+ large(0, 'Large'),
+ medium(1, 'Medium'),
+ small(2, 'Small');
+
+ const BadgeTextSize(this.code, this.label);
+ final int code;
+ final String label;
+
+ static BadgeTextSize? fromCode(int code) {
+ for (final s in BadgeTextSize.values) {
+ if (s.code == code) return s;
+ }
+ return null;
+ }
+}
+
+/// Which text size a TEXT_SET changes: the badge's Nav text or Message text.
+enum BadgeTextTarget {
+ navigation(0),
+ messages(1);
+
+ const BadgeTextTarget(this.code);
+ final int code;
+}
+
+/// The Screen off steps the badge's row cycles through (`kScreenOffSteps`).
+/// Firmware accepts exactly these and nothing else.
+const List screenOffSteps = [15, 30, 60, 120, 300];
+
+String screenOffLabel(int secs) => secs < 60
+ ? '$secs s'
+ : '${secs ~/ 60} m${secs % 60 == 0 ? '' : ' ${secs % 60} s'}';
+
+/// The radio's screen and battery settings, from any `0x13` to `0x17` reply.
+class ScreenSettingsState {
+ const ScreenSettingsState({
+ required this.navText,
+ required this.messageText,
+ required this.screenOffStored,
+ required this.screenOffSeconds,
+ required this.timeZone,
+ required this.batteryFullStored,
+ required this.batteryFullMv,
+ required this.batteryPinned,
+ });
+
+ /// Null when a newer firmware reports a size this client does not know.
+ final BadgeTextSize? navText;
+ final BadgeTextSize? messageText;
+
+ /// The Screen off preference in seconds; 0 means the board's compiled
+ /// timeout, which [screenOffSeconds] then reports.
+ final int screenOffStored;
+ final int screenOffSeconds;
+
+ /// Index into the badge's zone table; 0 is not set.
+ final int timeZone;
+
+ /// The learned or pinned full point in mV, 0 when there is neither.
+ final int batteryFullStored;
+
+ /// The mV the radio's battery bar treats as 100%.
+ final int batteryFullMv;
+
+ /// Set by hand, which auto-learn leaves alone.
+ final bool batteryPinned;
+
+ bool get screenOffIsDefault => screenOffStored == 0;
+}
+
+Uint8List buildScreenSettingsGetFrame() =>
+ Uint8List.fromList([cmdOffbandDeviceUi, offbandUiScreenGet]);
+
+Uint8List buildTextSizeSetFrame(BadgeTextTarget target, BadgeTextSize size) =>
+ Uint8List.fromList([
+ cmdOffbandDeviceUi,
+ offbandUiTextSet,
+ target.code,
+ size.code,
+ ]);
+
+Uint8List buildScreenOffSetFrame(int seconds) {
+ if (seconds < 0 || seconds > 0xFFFF) {
+ throw ArgumentError.value(seconds, 'seconds', 'must be 0 to 65535');
+ }
+ return Uint8List.fromList([
+ cmdOffbandDeviceUi,
+ offbandUiScreenOffSet,
+ seconds & 0xFF,
+ seconds >> 8,
+ ]);
+}
+
+Uint8List buildTimeZoneSetFrame(int index) {
+ if (index < 0 || index > 0xFF) {
+ throw ArgumentError.value(index, 'index', 'must be 0 to 255');
+ }
+ return Uint8List.fromList([cmdOffbandDeviceUi, offbandUiTimeZoneSet, index]);
+}
+
+/// `pin` true pins the radio's own current reading as 100%; false hands the
+/// full point back to auto-learn.
+Uint8List buildBatteryFullSetFrame({required bool pin}) =>
+ Uint8List.fromList([cmdOffbandDeviceUi, offbandUiBatterySet, pin ? 1 : 0]);
+
+/// Parse a screen-settings reply. Null if this is not one, or if it is
+/// shorter than the 14 bytes the contract fixes. The shared 0x7F error is
+/// owned by [parseButtonMatrixReply].
+OffbandUiReply? parseScreenSettingsReply(Uint8List frame) {
+ if (frame.length < 2 || frame[0] != respCodeOffbandDeviceUi) return null;
+ final sub = frame[1];
+ if (sub < offbandUiScreenGet || sub > offbandUiBatterySet) return null;
+ if (frame.length < _screenReplyLength) return null;
+ final data = ByteData.sublistView(frame);
+ return OffbandUiReply(
+ command: respCodeOffbandDeviceUi,
+ sub: sub,
+ screen: ScreenSettingsState(
+ navText: BadgeTextSize.fromCode(frame[2]),
+ messageText: BadgeTextSize.fromCode(frame[3]),
+ screenOffStored: data.getUint16(4, Endian.little),
+ screenOffSeconds: data.getUint16(6, Endian.little),
+ timeZone: frame[8],
+ batteryFullStored: data.getUint16(9, Endian.little),
+ batteryFullMv: data.getUint16(11, Endian.little),
+ batteryPinned: frame[13] != 0,
+ ),
+ );
+}
diff --git a/lib/connector/offband_tones.dart b/lib/connector/offband_tones.dart
new file mode 100644
index 00000000..5be07d89
--- /dev/null
+++ b/lib/connector/offband_tones.dart
@@ -0,0 +1,415 @@
+import 'dart:typed_data';
+
+import 'offband_device_ui.dart';
+
+/// The radio's tune system over `0xC5` (#970, #971, #986): the built-in tune
+/// table, the eight custom RTTTL slots, the six event tones and preview.
+///
+/// Gated on caps byte 2 `0x10` TUNES (meshcore-firmware `3e788e2a`). The
+/// contract lives in firmware's `OffbandConfigProtocol.h`; the tables and the
+/// validator below are ports of `Tunes.h` and `TuneSlots.h` at that commit.
+
+const int offbandUiSlotGet = 0x09;
+const int offbandUiSlotSet = 0x0A;
+const int offbandUiEventToneGet = 0x0F;
+const int offbandUiEventToneSet = 0x10;
+const int offbandUiTunePlay = 0x11;
+const int offbandUiSlotRead = 0x12;
+
+/// The built-in tunes, by id. Firmware's table is append-only, so this copy
+/// can only fall behind, never misname an id. A null name is a tune firmware
+/// keeps out of its own picker (id 7, Trigger), so it is never offered here.
+const List builtinTuneNames = [
+ 'Silent',
+ 'Chime',
+ 'Kerplop',
+ 'Tick',
+ 'Bloop',
+ 'Drop',
+ 'Knock',
+ null,
+];
+
+const int tuneSilent = 0;
+const int builtinTuneCount = 8;
+const int tuneSlotCount = 8;
+const int firstSlotTuneId = builtinTuneCount;
+
+/// The longest RTTTL a slot holds (`kMaxRtttl`) and the longest name a slot
+/// shows (`kMaxSlotName`).
+const int maxSlotRtttl = 240;
+const int maxSlotName = 12;
+
+bool isSlotTuneId(int id) =>
+ id >= firstSlotTuneId && id < firstSlotTuneId + tuneSlotCount;
+
+int slotTuneId(int slot) => firstSlotTuneId + slot;
+
+/// Built-in ids the badge's picker lists, in its order.
+List get listedBuiltinTuneIds => [
+ for (var id = 0; id < builtinTuneNames.length; id++)
+ if (builtinTuneNames[id] != null) id,
+];
+
+/// One of the six events the badge's Tones screen sets. Values are firmware's
+/// `offband::ToneEvent`, and the labels are the badge's own (`kToneEventRows`).
+enum ToneEvent {
+ dm(0, 'DM'),
+ channel(1, 'Channel'),
+ room(2, 'Room'),
+ newContact(3, 'New contact'),
+ ack(4, 'Ack'),
+ sent(5, 'Sent');
+
+ const ToneEvent(this.code, this.label);
+ final int code;
+ final String label;
+
+ static ToneEvent? fromCode(int code) {
+ for (final e in ToneEvent.values) {
+ if (e.code == code) return e;
+ }
+ return null;
+ }
+}
+
+/// Stored byte meaning "nothing chosen": the board's compiled default plays.
+const int tuneUnset = 0xFF;
+
+/// An event's tune as the radio reports it. [effective] is what plays.
+class EventToneState {
+ const EventToneState({required this.stored, required this.effective});
+ final int stored;
+ final int effective;
+
+ bool get isRadioDefault => stored == tuneUnset;
+}
+
+class EventToneReply {
+ const EventToneReply({required this.event, required this.state});
+ final ToneEvent event;
+ final EventToneState state;
+}
+
+/// What a custom slot holds, from `SLOT_GET`: its length and derived name,
+/// never the tune itself. Length 0 is an empty slot.
+class TuneSlotInfo {
+ const TuneSlotInfo({required this.length, required this.name});
+ final int length;
+ final String name;
+
+ bool get isEmpty => length == 0;
+}
+
+class TuneSlotReply {
+ const TuneSlotReply({required this.slot, required this.info});
+ final int slot;
+ final TuneSlotInfo info;
+}
+
+/// `SLOT_SET` progress: how many bytes the radio holds for the push in flight.
+class TuneSlotAccepted {
+ const TuneSlotAccepted({required this.slot, required this.accepted});
+ final int slot;
+ final int accepted;
+}
+
+/// One `SLOT_READ` chunk.
+class TuneSlotChunk {
+ const TuneSlotChunk({
+ required this.slot,
+ required this.offset,
+ required this.more,
+ required this.bytes,
+ });
+ final int slot;
+ final int offset;
+ final bool more;
+ final Uint8List bytes;
+}
+
+/// Why a preview did or did not sound (`offband::TonePlay`).
+enum TunePlayResult {
+ played(0),
+ busy(1),
+ soundOff(2);
+
+ const TunePlayResult(this.code);
+ final int code;
+
+ static TunePlayResult? fromCode(int code) {
+ for (final r in TunePlayResult.values) {
+ if (r.code == code) return r;
+ }
+ return null;
+ }
+}
+
+class TunePlayReply {
+ const TunePlayReply({required this.tuneId, required this.result});
+ final int tuneId;
+
+ /// Null when a newer firmware reports a result this client does not know.
+ final TunePlayResult? result;
+}
+
+/// What the client knows of a radio's tunes, for naming an id.
+///
+/// [slots] holds the `SLOT_GET` replies read so far, by slot index.
+String? tuneLabel(int id, Map slots) {
+ if (id >= 0 && id < builtinTuneNames.length) return builtinTuneNames[id];
+ if (isSlotTuneId(id)) {
+ final info = slots[id - firstSlotTuneId];
+ if (info == null || info.isEmpty) return null;
+ return info.name.isEmpty ? 'Slot ${id - firstSlotTuneId + 1}' : info.name;
+ }
+ return null;
+}
+
+/// The ids a picker may offer: firmware's rule, a named built-in or a filled
+/// slot. Firmware refuses everything else, so nothing else is shown.
+List offeredTuneIds(Map slots) => [
+ ...listedBuiltinTuneIds,
+ for (var s = 0; s < tuneSlotCount; s++)
+ if (slots[s] != null && !slots[s]!.isEmpty) slotTuneId(s),
+];
+
+Uint8List buildEventToneGetFrame(ToneEvent event) =>
+ Uint8List.fromList([cmdOffbandDeviceUi, offbandUiEventToneGet, event.code]);
+
+Uint8List buildEventToneSetFrame(ToneEvent event, int tuneId) =>
+ Uint8List.fromList([
+ cmdOffbandDeviceUi,
+ offbandUiEventToneSet,
+ event.code,
+ _byte(tuneId, 'tuneId'),
+ ]);
+
+Uint8List buildTunePlayFrame(int tuneId) => Uint8List.fromList([
+ cmdOffbandDeviceUi,
+ offbandUiTunePlay,
+ _byte(tuneId, 'tuneId'),
+]);
+
+Uint8List buildSlotGetFrame(int slot) =>
+ Uint8List.fromList([cmdOffbandDeviceUi, offbandUiSlotGet, _slotByte(slot)]);
+
+Uint8List buildSlotReadFrame(int slot, int offset) => Uint8List.fromList([
+ cmdOffbandDeviceUi,
+ offbandUiSlotRead,
+ _slotByte(slot),
+ _byte(offset, 'offset'),
+]);
+
+/// `[0xC5][0x0A][slot][offset][more][bytes...]`. An empty final chunk at
+/// offset 0 clears the slot.
+Uint8List buildSlotSetFrame(int slot, int offset, bool more, List bytes) =>
+ Uint8List.fromList([
+ cmdOffbandDeviceUi,
+ offbandUiSlotSet,
+ _slotByte(slot),
+ _byte(offset, 'offset'),
+ more ? 1 : 0,
+ ...bytes,
+ ]);
+
+int _slotByte(int slot) {
+ if (slot < 0 || slot >= tuneSlotCount) {
+ throw ArgumentError.value(
+ slot,
+ 'slot',
+ 'must be 0 to ${tuneSlotCount - 1}',
+ );
+ }
+ return slot;
+}
+
+int _byte(int value, String name) {
+ if (value < 0 || value > 0xFF) {
+ throw ArgumentError.value(value, name, 'must be 0 to 255');
+ }
+ return value;
+}
+
+/// Parse a tune reply (0x09, 0x0A, 0x0F, 0x10, 0x11, 0x12). Null if this is
+/// not one, or if it is truncated. The shared 0x7F error is owned by
+/// [parseButtonMatrixReply].
+OffbandUiReply? parseTuneReply(Uint8List frame) {
+ if (frame.length < 2 || frame[0] != respCodeOffbandDeviceUi) return null;
+ final sub = frame[1];
+ OffbandUiReply reply({
+ EventToneReply? eventTone,
+ TuneSlotReply? slot,
+ TuneSlotAccepted? accepted,
+ TuneSlotChunk? chunk,
+ TunePlayReply? play,
+ }) => OffbandUiReply(
+ command: respCodeOffbandDeviceUi,
+ sub: sub,
+ eventTone: eventTone,
+ tuneSlot: slot,
+ slotAccepted: accepted,
+ slotChunk: chunk,
+ tunePlay: play,
+ );
+
+ switch (sub) {
+ case offbandUiEventToneGet:
+ case offbandUiEventToneSet:
+ if (frame.length < 5) return null;
+ final event = ToneEvent.fromCode(frame[2]);
+ if (event == null) {
+ return OffbandUiReply(
+ command: respCodeOffbandDeviceUi,
+ sub: sub,
+ errorMessage:
+ 'The radio reported an unknown tone event (${frame[2]})',
+ );
+ }
+ return reply(
+ eventTone: EventToneReply(
+ event: event,
+ state: EventToneState(stored: frame[3], effective: frame[4]),
+ ),
+ );
+ case offbandUiSlotGet:
+ if (frame.length < 4) return null;
+ // The name is the rest of the frame, at most what the transport carried.
+ final name = String.fromCharCodes(frame.sublist(4));
+ return reply(
+ slot: TuneSlotReply(
+ slot: frame[2],
+ info: TuneSlotInfo(length: frame[3], name: name),
+ ),
+ );
+ case offbandUiSlotSet:
+ if (frame.length < 4) return null;
+ return reply(
+ accepted: TuneSlotAccepted(slot: frame[2], accepted: frame[3]),
+ );
+ case offbandUiSlotRead:
+ if (frame.length < 5) return null;
+ return reply(
+ chunk: TuneSlotChunk(
+ slot: frame[2],
+ offset: frame[3],
+ more: frame[4] != 0,
+ bytes: Uint8List.fromList(frame.sublist(5)),
+ ),
+ );
+ case offbandUiTunePlay:
+ if (frame.length < 4) return null;
+ return reply(
+ play: TunePlayReply(
+ tuneId: frame[2],
+ result: TunePlayResult.fromCode(frame[3]),
+ ),
+ );
+ }
+ return null;
+}
+
+/// Why an RTTTL string is not safe to play, as firmware's `rtttlProblem()`
+/// judges it. The same rule runs on the badge's editor and on every pushed
+/// tune, so the app checks with it before sending: the radio can only answer
+/// "malformed", and this says why.
+enum RtttlProblem {
+ ok('OK'),
+ empty('Empty'),
+ noName('Needs a name'),
+ missingColon('Needs name:notes'),
+ badDefault('Bad d=, o= or b='),
+ zeroTempo('b= must be 1 or more'),
+ noNotes('No notes'),
+ ambiguousNote('Add d=, o= or b= first'),
+ strayColon('Bad name or d=/o=/b=');
+
+ const RtttlProblem(this.text);
+
+ /// Firmware's own words (`rtttlProblemText`), so the app and the badge
+ /// describe the same problem the same way.
+ final String text;
+}
+
+bool _isDigit(int c) => c >= 0x30 && c <= 0x39;
+bool _isDefaultsSep(int c) => c == 0x2C || c == 0x3A;
+
+const int _colon = 0x3A;
+
+/// Port of firmware `rtttlProblem()` (`Tunes.h`, `3e788e2a`), step for step.
+RtttlProblem rtttlProblem(String rtttl) {
+ final s = rtttl.codeUnits;
+ if (s.isEmpty) return RtttlProblem.empty;
+ int at(int i) => i < s.length ? s[i] : 0;
+
+ var p = 0;
+ while (at(p) != 0 && at(p) != _colon) {
+ p++;
+ }
+ if (at(p) == 0) return RtttlProblem.missingColon;
+ if (p == 0) return RtttlProblem.noName;
+ p++;
+
+ var sawDefault = false;
+
+ if (at(p) == 0x64 && at(p + 1) == 0x3D) {
+ p += 2;
+ var digits = 0;
+ while (_isDigit(at(p))) {
+ p++;
+ digits++;
+ }
+ if (digits == 0 || digits > 4) return RtttlProblem.badDefault;
+ if (!_isDefaultsSep(at(p))) return RtttlProblem.badDefault;
+ p++;
+ sawDefault = true;
+ }
+
+ if (at(p) == 0x6F && at(p + 1) == 0x3D) {
+ if (!_isDigit(at(p + 2))) return RtttlProblem.badDefault;
+ if (!_isDefaultsSep(at(p + 3))) return RtttlProblem.badDefault;
+ p += 4;
+ sawDefault = true;
+ }
+
+ if (at(p) == 0x62 && at(p + 1) == 0x3D) {
+ p += 2;
+ var digits = 0;
+ var bpm = 0;
+ while (_isDigit(at(p))) {
+ if (digits < 5) bpm = bpm * 10 + (at(p) - 0x30);
+ p++;
+ digits++;
+ }
+ if (digits == 0) return RtttlProblem.zeroTempo;
+ if (digits > 4) return RtttlProblem.badDefault;
+ if (bpm == 0) return RtttlProblem.zeroTempo;
+ if (!_isDefaultsSep(at(p))) return RtttlProblem.badDefault;
+ p++;
+ sawDefault = true;
+ }
+
+ if (at(p) == 0) return RtttlProblem.noNotes;
+
+ final c = at(p);
+ if (!sawDefault && (c == 0x64 || c == 0x6F || c == 0x62)) {
+ return RtttlProblem.ambiguousNote;
+ }
+
+ for (var q = p; q < s.length; q++) {
+ if (s[q] == _colon) return RtttlProblem.strayColon;
+ }
+ return RtttlProblem.ok;
+}
+
+/// Port of firmware `deriveSlotName()`: the text before the first colon,
+/// trimmed and cut to [maxSlotName]. Empty when there is no usable name.
+String deriveSlotName(String rtttl) {
+ final colon = rtttl.indexOf(':');
+ if (colon < 0) return '';
+ var name = rtttl
+ .substring(0, colon)
+ .replaceAll(RegExp(r'^[ \t]+|[ \t]+$'), '');
+ if (name.length > maxSlotName) name = name.substring(0, maxSlotName);
+ return name;
+}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 2a9fd297..eca8589f 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -326,6 +326,18 @@
"settings_gpsSleepInvalid": "Enter a whole number of seconds, 0 or more.",
"settings_buttonActions": "Button actions",
"settings_buttonActionsSubtitle": "Assign what the button does",
+ "settings_badgeScreen": "Screen",
+ "settings_badgeScreenSubtitle": "Text size, screen off, time zone",
+ "settings_badgeBattery": "Battery",
+ "settings_badgeBatterySubtitle": "100% at {mv} mV",
+ "@settings_badgeBatterySubtitle": {
+ "placeholders": {
+ "mv": {
+ "type": "int"
+ }
+ }
+ },
+ "settings_badgeBatteryReading": "Reading from the radio",
"settings_buzzerTones": "Buzzer and tones",
"settings_buzzerTonesSubtitle": "When this radio beeps",
"settings_contactSettings": "Contact Settings",
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 119dee78..0f97dd41 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -1174,6 +1174,36 @@ abstract class AppLocalizations {
/// **'Assign what the button does'**
String get settings_buttonActionsSubtitle;
+ /// No description provided for @settings_badgeScreen.
+ ///
+ /// In en, this message translates to:
+ /// **'Screen'**
+ String get settings_badgeScreen;
+
+ /// No description provided for @settings_badgeScreenSubtitle.
+ ///
+ /// In en, this message translates to:
+ /// **'Text size, screen off, time zone'**
+ String get settings_badgeScreenSubtitle;
+
+ /// No description provided for @settings_badgeBattery.
+ ///
+ /// In en, this message translates to:
+ /// **'Battery'**
+ String get settings_badgeBattery;
+
+ /// No description provided for @settings_badgeBatterySubtitle.
+ ///
+ /// In en, this message translates to:
+ /// **'100% at {mv} mV'**
+ String settings_badgeBatterySubtitle(int mv);
+
+ /// No description provided for @settings_badgeBatteryReading.
+ ///
+ /// In en, this message translates to:
+ /// **'Reading from the radio'**
+ String get settings_badgeBatteryReading;
+
/// No description provided for @settings_buzzerTones.
///
/// In en, this message translates to:
diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart
index 8391a1e5..9ad203f8 100644
--- a/lib/l10n/app_localizations_bg.dart
+++ b/lib/l10n/app_localizations_bg.dart
@@ -624,6 +624,23 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart
index 3f8727bf..34dcc2d1 100644
--- a/lib/l10n/app_localizations_de.dart
+++ b/lib/l10n/app_localizations_de.dart
@@ -626,6 +626,23 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index fbe4226b..91234d98 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -620,6 +620,23 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart
index 9c08b90e..df7b515a 100644
--- a/lib/l10n/app_localizations_es.dart
+++ b/lib/l10n/app_localizations_es.dart
@@ -624,6 +624,23 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart
index 466a85f0..06e3f094 100644
--- a/lib/l10n/app_localizations_fr.dart
+++ b/lib/l10n/app_localizations_fr.dart
@@ -627,6 +627,23 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart
index a8c9d72e..8ec19aec 100644
--- a/lib/l10n/app_localizations_hu.dart
+++ b/lib/l10n/app_localizations_hu.dart
@@ -626,6 +626,23 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart
index 776a766f..fd297963 100644
--- a/lib/l10n/app_localizations_it.dart
+++ b/lib/l10n/app_localizations_it.dart
@@ -626,6 +626,23 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart
index 36e8383c..ebc0c8ce 100644
--- a/lib/l10n/app_localizations_ja.dart
+++ b/lib/l10n/app_localizations_ja.dart
@@ -609,6 +609,23 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart
index adec5408..1a889b5f 100644
--- a/lib/l10n/app_localizations_ko.dart
+++ b/lib/l10n/app_localizations_ko.dart
@@ -609,6 +609,23 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart
index 3624ba23..492c0739 100644
--- a/lib/l10n/app_localizations_nl.dart
+++ b/lib/l10n/app_localizations_nl.dart
@@ -623,6 +623,23 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart
index 54678f8c..af099e52 100644
--- a/lib/l10n/app_localizations_pl.dart
+++ b/lib/l10n/app_localizations_pl.dart
@@ -629,6 +629,23 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart
index dfa50e00..576a485a 100644
--- a/lib/l10n/app_localizations_pt.dart
+++ b/lib/l10n/app_localizations_pt.dart
@@ -626,6 +626,23 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart
index 2b5d4c9f..d6111d3a 100644
--- a/lib/l10n/app_localizations_ru.dart
+++ b/lib/l10n/app_localizations_ru.dart
@@ -625,6 +625,23 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart
index 41bf9d82..c6b8da96 100644
--- a/lib/l10n/app_localizations_sk.dart
+++ b/lib/l10n/app_localizations_sk.dart
@@ -624,6 +624,23 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart
index f6288d99..d01c0c18 100644
--- a/lib/l10n/app_localizations_sl.dart
+++ b/lib/l10n/app_localizations_sl.dart
@@ -622,6 +622,23 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart
index 41e3dbb1..8a3d895f 100644
--- a/lib/l10n/app_localizations_sv.dart
+++ b/lib/l10n/app_localizations_sv.dart
@@ -621,6 +621,23 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart
index 79efce02..7e2c6b70 100644
--- a/lib/l10n/app_localizations_uk.dart
+++ b/lib/l10n/app_localizations_uk.dart
@@ -623,6 +623,23 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart
index 6c89bd3b..c5164b8b 100644
--- a/lib/l10n/app_localizations_zh.dart
+++ b/lib/l10n/app_localizations_zh.dart
@@ -605,6 +605,23 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settings_buttonActionsSubtitle => 'Assign what the button does';
+ @override
+ String get settings_badgeScreen => 'Screen';
+
+ @override
+ String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
+
+ @override
+ String get settings_badgeBattery => 'Battery';
+
+ @override
+ String settings_badgeBatterySubtitle(int mv) {
+ return '100% at $mv mV';
+ }
+
+ @override
+ String get settings_badgeBatteryReading => 'Reading from the radio';
+
@override
String get settings_buzzerTones => 'Buzzer and tones';
diff --git a/lib/models/badge_time_zones.dart b/lib/models/badge_time_zones.dart
new file mode 100644
index 00000000..320f6d14
--- /dev/null
+++ b/lib/models/badge_time_zones.dart
@@ -0,0 +1,159 @@
+/// The badge's time zone table, a copy of firmware `src/helpers/TimeZones.h`
+/// at meshcore-firmware `3e788e2a` (#987).
+///
+/// The radio stores a zone as an index into this list, so the order is the
+/// contract. Firmware keeps it append-only ("Append only; never reorder"), so
+/// a copy can only fall behind; an index past the end is shown by number.
+library;
+
+enum DstRule { none, us, eu }
+
+class BadgeTimeZone {
+ const BadgeTimeZone(this.name, this.standardMinutes, this.rule);
+ final String name;
+ final int standardMinutes;
+ final DstRule rule;
+}
+
+const int timeZoneNotSet = 0;
+
+const List badgeTimeZones = [
+ BadgeTimeZone('not set', 0, DstRule.none),
+ BadgeTimeZone('UTC', 0, DstRule.none),
+ BadgeTimeZone('Eastern', -300, DstRule.us),
+ BadgeTimeZone('Central', -360, DstRule.us),
+ BadgeTimeZone('Mountain', -420, DstRule.us),
+ BadgeTimeZone('Arizona', -420, DstRule.none),
+ BadgeTimeZone('Pacific', -480, DstRule.us),
+ BadgeTimeZone('Alaska', -540, DstRule.us),
+ BadgeTimeZone('Hawaii', -600, DstRule.none),
+ BadgeTimeZone('UK', 0, DstRule.eu),
+ BadgeTimeZone('Central EU', 60, DstRule.eu),
+ BadgeTimeZone('Eastern EU', 120, DstRule.eu),
+ BadgeTimeZone('UTC-12', -720, DstRule.none),
+ BadgeTimeZone('UTC-11', -660, DstRule.none),
+ BadgeTimeZone('UTC-10', -600, DstRule.none),
+ BadgeTimeZone('UTC-9', -540, DstRule.none),
+ BadgeTimeZone('UTC-8', -480, DstRule.none),
+ BadgeTimeZone('UTC-7', -420, DstRule.none),
+ BadgeTimeZone('UTC-6', -360, DstRule.none),
+ BadgeTimeZone('UTC-5', -300, DstRule.none),
+ BadgeTimeZone('UTC-4', -240, DstRule.none),
+ BadgeTimeZone('UTC-3', -180, DstRule.none),
+ BadgeTimeZone('UTC-2', -120, DstRule.none),
+ BadgeTimeZone('UTC-1', -60, DstRule.none),
+ BadgeTimeZone('UTC+1', 60, DstRule.none),
+ BadgeTimeZone('UTC+2', 120, DstRule.none),
+ BadgeTimeZone('UTC+3', 180, DstRule.none),
+ BadgeTimeZone('UTC+4', 240, DstRule.none),
+ BadgeTimeZone('UTC+5', 300, DstRule.none),
+ BadgeTimeZone('UTC+6', 360, DstRule.none),
+ BadgeTimeZone('UTC+7', 420, DstRule.none),
+ BadgeTimeZone('UTC+8', 480, DstRule.none),
+ BadgeTimeZone('UTC+9', 540, DstRule.none),
+ BadgeTimeZone('UTC+10', 600, DstRule.none),
+ BadgeTimeZone('UTC+11', 660, DstRule.none),
+ BadgeTimeZone('UTC+12', 720, DstRule.none),
+ BadgeTimeZone('UTC+13', 780, DstRule.none),
+ BadgeTimeZone('UTC+14', 840, DstRule.none),
+ BadgeTimeZone('UTC-3:30', -210, DstRule.none),
+ BadgeTimeZone('UTC+5:30', 330, DstRule.none),
+ BadgeTimeZone('UTC+9:30', 570, DstRule.none),
+];
+
+/// The first fixed-offset zone (`kFirstFixed`).
+const int _firstFixedZone = 12;
+
+bool _isSet(int index) => index > 0 && index < badgeTimeZones.length;
+
+/// The zone's name, or the raw index for one this client does not know.
+String timeZoneName(int index) => index >= 0 && index < badgeTimeZones.length
+ ? badgeTimeZones[index].name
+ : 'Zone $index';
+
+int _nthSunday(int year, int month, int n) {
+ final first = DateTime.utc(year, month, 1);
+ final toSunday = (DateTime.sunday - first.weekday) % 7;
+ return 1 + toSunday + 7 * (n - 1);
+}
+
+int _lastSunday(int year, int month) {
+ final last = DateTime.utc(year, month + 1, 0);
+ return last.day - (last.weekday % 7);
+}
+
+/// Port of firmware `inDaylightSaving()`: US from 02:00 local on the second
+/// Sunday of March to 02:00 local daylight time on the first Sunday of
+/// November; EU from 01:00 UTC on the last Sunday of March to 01:00 UTC on
+/// the last Sunday of October.
+bool _inDaylightSaving(BadgeTimeZone z, DateTime utc) {
+ final y = utc.year;
+ switch (z.rule) {
+ case DstRule.us:
+ final start = DateTime.utc(
+ y,
+ 3,
+ _nthSunday(y, 3, 2),
+ 2,
+ ).subtract(Duration(minutes: z.standardMinutes));
+ final end = DateTime.utc(
+ y,
+ 11,
+ _nthSunday(y, 11, 1),
+ 2,
+ ).subtract(Duration(minutes: z.standardMinutes + 60));
+ return !utc.isBefore(start) && utc.isBefore(end);
+ case DstRule.eu:
+ final start = DateTime.utc(y, 3, _lastSunday(y, 3), 1);
+ final end = DateTime.utc(y, 10, _lastSunday(y, 10), 1);
+ return !utc.isBefore(start) && utc.isBefore(end);
+ case DstRule.none:
+ return false;
+ }
+}
+
+/// Minutes to add to UTC at [utc] in zone [index], daylight saving included.
+/// Null for "not set" and for an index this client does not know.
+int? timeZoneOffsetMinutes(int index, DateTime utc) {
+ if (!_isSet(index)) return null;
+ final z = badgeTimeZones[index];
+ return z.standardMinutes + (_inDaylightSaving(z, utc.toUtc()) ? 60 : 0);
+}
+
+/// "UTC-4", "UTC+5:30", "UTC+0".
+String formatUtcOffset(int minutes) {
+ final sign = minutes < 0 ? '-' : '+';
+ final a = minutes.abs();
+ final mins = a % 60;
+ return 'UTC$sign${a ~/ 60}${mins == 0 ? '' : ':${mins.toString().padLeft(2, '0')}'}';
+}
+
+/// The zone that matches the phone, as the badge suggests one from GPS.
+///
+/// A named zone matches when its offset equals the phone's now, in January and
+/// in July, so its daylight rule agrees as well as its current offset. Failing
+/// that, the fixed zone for the phone's current offset. Null when nothing fits.
+///
+/// [offsetAt] is the phone's UTC offset at a local instant; injectable so the
+/// match can be tested without depending on the machine's own zone.
+int? suggestTimeZone({
+ required DateTime now,
+ required Duration Function(DateTime local) offsetAt,
+}) {
+ final year = now.year;
+ final probes = [now, DateTime(year, 1, 15, 12), DateTime(year, 7, 15, 12)];
+ // From 2: UTC (1) is the fallback below, so a UK phone in winter is not
+ // offered plain UTC.
+ for (var i = 2; i < _firstFixedZone; i++) {
+ final matches = probes.every(
+ (t) => timeZoneOffsetMinutes(i, t.toUtc()) == offsetAt(t).inMinutes,
+ );
+ if (matches) return i;
+ }
+ final current = offsetAt(now).inMinutes;
+ if (current == 0) return 1;
+ for (var i = _firstFixedZone; i < badgeTimeZones.length; i++) {
+ if (badgeTimeZones[i].standardMinutes == current) return i;
+ }
+ return null;
+}
diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart
index 6fea9111..e3fdc41c 100644
--- a/lib/screens/channel_chat_screen.dart
+++ b/lib/screens/channel_chat_screen.dart
@@ -31,6 +31,7 @@ import '../services/corescope_service.dart';
import '../services/region_discovery_service.dart';
import '../widgets/channel_region_scope_dialog.dart';
import '../widgets/channel_notify_mode.dart';
+import '../widgets/radio_tone.dart';
import '../services/block_service.dart';
import '../services/chat_text_scale_service.dart';
import '../services/translation_service.dart';
@@ -473,6 +474,10 @@ class _ChannelChatScreenState extends State {
onSelected: (value) {
if (value == 'notifications') {
showChannelNotifyModeDialog(context, widget.channel);
+ } else if (value == 'radioTone') {
+ final connector = context.read();
+ requestChannelRadioTone(connector, _currentChannel.index);
+ showChannelRadioToneDialog(context, _currentChannel);
} else if (value == 'clearChat') {
context.read().clearMessagesForChannel(
_currentChannel.index,
@@ -502,6 +507,17 @@ class _ChannelChatScreenState extends State {
],
),
),
+ if (context.read().supportsTunes)
+ const PopupMenuItem(
+ value: 'radioTone',
+ child: Row(
+ children: [
+ Icon(Icons.music_note_outlined, size: 20),
+ SizedBox(width: 12),
+ Text('Radio tone'),
+ ],
+ ),
+ ),
if (context.read().supportsRegionScope)
PopupMenuItem(
value: 'setRegionScope',
diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart
index a308eb17..1b2ec301 100644
--- a/lib/screens/channels_screen.dart
+++ b/lib/screens/channels_screen.dart
@@ -27,6 +27,7 @@ import '../widgets/channel_drawer_list.dart';
import '../widgets/sync_progress_overlay.dart';
import '../widgets/unread_badge.dart';
import '../widgets/channel_notify_mode.dart';
+import '../widgets/radio_tone.dart';
import '../widgets/battery_optimization_banner.dart';
import '../helpers/snack_bar_builder.dart';
import 'channel_chat_screen.dart';
@@ -534,6 +535,18 @@ class _ChannelsScreenState extends State
}
},
),
+ // The radio's own tone for this channel (#971), named apart from
+ // Notifications, which is the phone's. Gated on caps byte 2 0x10.
+ ChannelRadioToneTile(
+ channel: channel,
+ onTap: () async {
+ Navigator.pop(sheetContext);
+ await Future.delayed(const Duration(milliseconds: 100));
+ if (parentContext.mounted) {
+ showChannelRadioToneDialog(parentContext, channel);
+ }
+ },
+ ),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: Text(
diff --git a/lib/screens/settings/badge_battery_view.dart b/lib/screens/settings/badge_battery_view.dart
new file mode 100644
index 00000000..4014784c
--- /dev/null
+++ b/lib/screens/settings/badge_battery_view.dart
@@ -0,0 +1,125 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../connector/meshcore_connector.dart';
+import '../../connector/offband_device_ui.dart';
+import '../../connector/offband_screen_settings.dart';
+import '../../widgets/device_ui_error_banner.dart';
+
+/// Where the badge's battery bar puts 100%, and how it got there (#987):
+/// the badge's own Battery screen, with its two actions. Gated on caps byte 2
+/// `0x40`. English-only, matching [DeviceUiView].
+class BadgeBatteryView extends StatefulWidget {
+ const BadgeBatteryView({super.key});
+
+ @override
+ State createState() => _BadgeBatteryViewState();
+}
+
+/// Where the full point came from, in the badge's terms (default, learned,
+/// set). Shared with the Node Settings row.
+String batteryFullSource(ScreenSettingsState s) => s.batteryFullStored == 0
+ ? 'Default for this board'
+ : s.batteryPinned
+ ? 'Set by hand'
+ : 'Learned after a full charge';
+
+class _BadgeBatteryViewState extends State {
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) context.read().requestScreenSettings();
+ });
+ }
+
+ Future _pin(MeshCoreConnector connector) async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (dialogContext) => AlertDialog(
+ title: const Text('Set 100% here?'),
+ content: const Text(
+ 'Only do this with the radio fully charged. The radio uses its '
+ 'current reading as full, and stops learning it.',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, true),
+ child: const Text('Set 100% here'),
+ ),
+ ],
+ ),
+ );
+ if (confirmed == true) await connector.setBatteryFull(pin: true);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final connector = context.watch();
+ final s = connector.screenSettings;
+ final error = connector.deviceUiError;
+ final textTheme = Theme.of(context).textTheme;
+ final mv = connector.batteryMillivolts;
+ final pct = connector.batteryPercent;
+ return ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ if (error != null &&
+ connector.deviceUiErrorSurface == DeviceUiSurface.screenSettings)
+ DeviceUiErrorBanner(
+ message: error,
+ onDismiss: connector.clearDeviceUiError,
+ margin: const EdgeInsets.only(bottom: 12),
+ ),
+ if (pct != null) Text('$pct%', style: textTheme.headlineMedium),
+ if (mv != null) Text('Reading $mv mV'),
+ const SizedBox(height: 12),
+ if (s == null)
+ const ListTile(
+ leading: SizedBox(
+ width: 24,
+ height: 24,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ ),
+ title: Text('Reading battery settings from the radio'),
+ )
+ else ...[
+ Card(
+ child: ListTile(
+ leading: const Icon(Icons.battery_std),
+ title: Text('100% at ${s.batteryFullMv} mV'),
+ subtitle: Text(batteryFullSource(s)),
+ ),
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ 'The radio learns where full is after a complete charge. Set it by '
+ 'hand only if the bar never reaches 100% when charged.',
+ ),
+ const SizedBox(height: 16),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ FilledButton.tonalIcon(
+ onPressed: () => _pin(connector),
+ icon: const Icon(Icons.bolt),
+ label: const Text('Set 100% here'),
+ ),
+ // Only when there is something to hand back, as on the badge.
+ if (s.batteryFullStored != 0)
+ TextButton(
+ onPressed: () => connector.setBatteryFull(pin: false),
+ child: const Text('Back to auto'),
+ ),
+ ],
+ ),
+ ],
+ ],
+ );
+ }
+}
diff --git a/lib/screens/settings/badge_screen_view.dart b/lib/screens/settings/badge_screen_view.dart
new file mode 100644
index 00000000..baf42794
--- /dev/null
+++ b/lib/screens/settings/badge_screen_view.dart
@@ -0,0 +1,262 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../connector/meshcore_connector.dart';
+import '../../connector/offband_device_ui.dart';
+import '../../connector/offband_screen_settings.dart';
+import '../../models/badge_time_zones.dart';
+import '../../widgets/device_ui_error_banner.dart';
+
+/// The badge's own screen settings (#987): its Nav text, Message text, Screen
+/// off and Time zone rows, with the same choices and no others. Gated on caps
+/// byte 2 `0x40`; the Node Settings row that leads here is gated the same way.
+///
+/// Every control draws from the radio's re-read reply, never from the tap.
+/// English-only, matching [DeviceUiView].
+class BadgeScreenView extends StatefulWidget {
+ const BadgeScreenView({super.key});
+
+ @override
+ State createState() => _BadgeScreenViewState();
+}
+
+class _BadgeScreenViewState extends State {
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (mounted) context.read().requestScreenSettings();
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final connector = context.watch();
+ final s = connector.screenSettings;
+ final error = connector.deviceUiError;
+ return ListView(
+ children: [
+ if (error != null &&
+ connector.deviceUiErrorSurface == DeviceUiSurface.screenSettings)
+ DeviceUiErrorBanner(
+ message: error,
+ onDismiss: connector.clearDeviceUiError,
+ ),
+ if (s == null)
+ const ListTile(
+ leading: SizedBox(
+ width: 24,
+ height: 24,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ ),
+ title: Text('Reading screen settings from the radio'),
+ )
+ else ...[
+ const _Header('Text size'),
+ // The badge's Nav text and Message text, named for what they govern
+ // (owner decision 2026-10-25).
+ _TextSizeRow(
+ label: 'Menus and lists',
+ value: s.navText,
+ onChanged: (v) =>
+ connector.setTextSize(BadgeTextTarget.navigation, v),
+ ),
+ _TextSizeRow(
+ label: 'Messages',
+ value: s.messageText,
+ onChanged: (v) =>
+ connector.setTextSize(BadgeTextTarget.messages, v),
+ ),
+ const _Header('Screen off'),
+ const Padding(
+ padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
+ child: Text(
+ 'How long the screen stays on after the last key press.',
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: SegmentedButton(
+ segments: [
+ for (final secs in screenOffSteps)
+ ButtonSegment(value: secs, label: Text(screenOffLabel(secs))),
+ ],
+ selected: {
+ if (screenOffSteps.contains(s.screenOffStored))
+ s.screenOffStored,
+ },
+ emptySelectionAllowed: true,
+ onSelectionChanged: (v) {
+ if (v.isNotEmpty) connector.setScreenOff(v.first);
+ },
+ ),
+ ),
+ if (!screenOffSteps.contains(s.screenOffStored))
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
+ child: Text(
+ s.screenOffIsDefault
+ ? 'Board default: ${screenOffLabel(s.screenOffSeconds)}'
+ : 'Set to ${screenOffLabel(s.screenOffStored)} elsewhere',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ),
+ const _Header('Time zone'),
+ ListTile(
+ leading: const Icon(Icons.public),
+ title: Text(timeZoneName(s.timeZone)),
+ subtitle: Text(_zoneSubtitle(s.timeZone)),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () => Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) => TimeZonePickerScreen(current: s.timeZone),
+ ),
+ ),
+ ),
+ ],
+ ],
+ );
+ }
+
+ String _zoneSubtitle(int index) {
+ final offset = timeZoneOffsetMinutes(index, DateTime.now().toUtc());
+ if (offset == null) {
+ return index == timeZoneNotSet
+ ? 'Messages show their age'
+ : 'A zone this app does not know';
+ }
+ return '${formatUtcOffset(offset)} now. Message times show as clock times.';
+ }
+}
+
+class _Header extends StatelessWidget {
+ const _Header(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),
+ );
+}
+
+class _TextSizeRow extends StatelessWidget {
+ const _TextSizeRow({
+ required this.label,
+ required this.value,
+ required this.onChanged,
+ });
+
+ final String label;
+ final BadgeTextSize? value;
+ final ValueChanged onChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(label),
+ const SizedBox(height: 4),
+ SizedBox(
+ width: double.infinity,
+ child: SegmentedButton(
+ segments: [
+ for (final size in BadgeTextSize.values)
+ ButtonSegment(value: size, label: Text(size.label)),
+ ],
+ selected: {?value},
+ emptySelectionAllowed: true,
+ onSelectionChanged: (v) {
+ if (v.isNotEmpty) onChanged(v.first);
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// The badge's zone list, with "not set" and a suggestion from the phone's
+/// own zone, as the badge suggests one from GPS.
+class TimeZonePickerScreen extends StatefulWidget {
+ const TimeZonePickerScreen({super.key, required this.current});
+
+ final int current;
+
+ @override
+ State createState() => _TimeZonePickerScreenState();
+}
+
+class _TimeZonePickerScreenState extends State {
+ String _query = '';
+
+ void _choose(int index) {
+ context.read().setTimeZone(index);
+ Navigator.pop(context);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final now = DateTime.now();
+ final suggested = suggestTimeZone(
+ now: now,
+ offsetAt: (local) => local.timeZoneOffset,
+ );
+ final q = _query.trim().toLowerCase();
+ bool shown(int i) => q.isEmpty || timeZoneName(i).toLowerCase().contains(q);
+ final scheme = Theme.of(context).colorScheme;
+
+ Widget zoneTile(int i, {bool suggestion = false}) {
+ final offset = timeZoneOffsetMinutes(i, now.toUtc());
+ final selected = !suggestion && i == widget.current;
+ return ListTile(
+ leading: suggestion
+ ? const Icon(Icons.smartphone)
+ : Icon(
+ selected
+ ? Icons.radio_button_checked
+ : Icons.radio_button_unchecked,
+ color: selected ? scheme.primary : null,
+ ),
+ title: Text(timeZoneName(i)),
+ subtitle: suggestion
+ ? const Text('Matches this phone')
+ : selected
+ ? const Text('Current')
+ : i == timeZoneNotSet
+ ? const Text('Messages show their age')
+ : null,
+ trailing: offset == null ? null : Text(formatUtcOffset(offset)),
+ selected: selected,
+ onTap: () => _choose(i),
+ );
+ }
+
+ return Scaffold(
+ appBar: AppBar(title: const Text('Time zone'), centerTitle: true),
+ body: ListView(
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
+ child: SearchBar(
+ hintText: 'Search zones',
+ leading: const Icon(Icons.search),
+ onChanged: (v) => setState(() => _query = v),
+ ),
+ ),
+ if (suggested != null && shown(suggested)) ...[
+ zoneTile(suggested, suggestion: true),
+ const Divider(height: 1),
+ ],
+ for (var i = 0; i < badgeTimeZones.length; i++)
+ if (shown(i)) zoneTile(i),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/screens/settings/device_ui_view.dart b/lib/screens/settings/device_ui_view.dart
index e9a6f40c..c0a8d016 100644
--- a/lib/screens/settings/device_ui_view.dart
+++ b/lib/screens/settings/device_ui_view.dart
@@ -3,6 +3,10 @@ import 'package:provider/provider.dart';
import '../../connector/meshcore_connector.dart';
import '../../connector/offband_device_ui.dart';
+import '../../connector/offband_tones.dart';
+import '../../widgets/device_ui_error_banner.dart';
+import '../../widgets/radio_tone.dart';
+import 'tune_slot_editor_screen.dart';
/// Settings for a headless device's physical UI: the button-action matrix
/// (#474) and the device notification scope (#475).
@@ -43,6 +47,10 @@ class _DeviceUiViewState extends State {
c.requestButtonMatrix();
case DeviceUiPart.buzzer:
c.requestNotifyScope();
+ if (c.supportsTunes) {
+ c.requestEventTones();
+ c.requestTuneSlots();
+ }
}
});
}
@@ -53,8 +61,11 @@ class _DeviceUiViewState extends State {
null => true,
DeviceUiSurface.buttonMatrix => widget.part == DeviceUiPart.buttons,
DeviceUiSurface.notifyScope ||
- DeviceUiSurface.channelTune => widget.part == DeviceUiPart.buzzer,
- DeviceUiSurface.gpsMode => false,
+ DeviceUiSurface.channelTune ||
+ DeviceUiSurface.eventTone ||
+ DeviceUiSurface.tunePlay ||
+ DeviceUiSurface.tuneSlot => widget.part == DeviceUiPart.buzzer,
+ DeviceUiSurface.gpsMode || DeviceUiSurface.screenSettings => false,
};
@override
@@ -110,7 +121,7 @@ class _DeviceUiViewState extends State {
children: [
if (connector.deviceUiError != null &&
_ownsError(connector.deviceUiErrorSurface))
- _ErrorBanner(
+ DeviceUiErrorBanner(
message: connector.deviceUiError!,
onDismiss: connector.clearDeviceUiError,
),
@@ -125,6 +136,25 @@ class _DeviceUiViewState extends State {
),
),
..._scopeTiles(connector),
+ // The badge's Tones screen, in its order: the scope gates every
+ // tone below it (#986, #970). Needs the tune engine, 0x10.
+ if (connector.supportsTunes) ...[
+ const _SectionHeader('Tones'),
+ const Padding(
+ padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
+ child: Text('What this radio plays for each event.'),
+ ),
+ ..._eventToneTiles(connector),
+ const _SectionHeader('Custom tunes'),
+ const Padding(
+ padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
+ child: Text(
+ 'Eight tunes stored on this radio, in RTTTL. A saved tune '
+ 'can be picked for any event or channel.',
+ ),
+ ),
+ ..._slotTiles(connector),
+ ],
],
if (showButtons) ...[
const _SectionHeader('Button actions'),
@@ -182,6 +212,111 @@ class _DeviceUiViewState extends State {
.toList();
}
+ /// The six events, each naming the tune that plays. "(radio default)" marks
+ /// one nobody has chosen, so a choice and a default never look alike.
+ List _eventToneTiles(MeshCoreConnector connector) {
+ return ToneEvent.values.map((event) {
+ final state = connector.eventTone(event);
+ if (state == null) {
+ return ListTile(
+ leading: const Icon(Icons.music_note_outlined),
+ title: Text(event.label),
+ subtitle: const Text('Reading from the radio'),
+ );
+ }
+ final name =
+ tuneLabel(state.effective, connector.tuneSlots) ??
+ 'Tune ${state.effective}';
+ return ListTile(
+ leading: Icon(tuneIcon(state.effective)),
+ title: Text(event.label),
+ subtitle: Text(state.isRadioDefault ? '$name (radio default)' : name),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () => _pickEventTone(connector, event, state),
+ );
+ }).toList();
+ }
+
+ Future _pickEventTone(
+ MeshCoreConnector connector,
+ ToneEvent event,
+ EventToneState state,
+ ) async {
+ // No Default row: firmware refuses 0xFF on an event, as the badge's own
+ // picker offers none (owner decision 2026-10-25).
+ final choice = await showTunePickerDialog(
+ context,
+ title: '${event.label} tone',
+ description: event == ToneEvent.dm
+ ? 'Plays for every direct message.'
+ : null,
+ current: state.effective,
+ );
+ if (choice is TuneIdChoice) await connector.setEventTone(event, choice.id);
+ }
+
+ /// All eight slots, as the badge lists them: an empty slot is where you go
+ /// to fill one, so it is a row too.
+ List _slotTiles(MeshCoreConnector connector) {
+ return [
+ for (var slot = 0; slot < tuneSlotCount; slot++)
+ _slotTile(connector, slot, connector.tuneSlots[slot]),
+ ];
+ }
+
+ Widget _slotTile(MeshCoreConnector connector, int slot, TuneSlotInfo? info) {
+ final number = slot + 1;
+ if (info == null) {
+ return ListTile(
+ leading: const Icon(Icons.music_note_outlined),
+ title: Text('$number'),
+ subtitle: const Text('Reading from the radio'),
+ );
+ }
+ void open() => Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) => TuneSlotEditorScreen(slot: slot, info: info),
+ ),
+ );
+ if (info.isEmpty) {
+ return ListTile(
+ leading: const Icon(Icons.add_circle_outline),
+ title: Text('$number (empty)'),
+ subtitle: const Text('Tap to add a tune'),
+ onTap: open,
+ );
+ }
+ final name = info.name.isEmpty ? 'Slot $number' : info.name;
+ return ListTile(
+ leading: const Icon(Icons.music_note_outlined),
+ title: Text('$number $name'),
+ subtitle: Text('${info.length} characters'),
+ trailing: IconButton(
+ icon: const Icon(Icons.play_circle_outline),
+ color: Theme.of(context).colorScheme.primary,
+ tooltip: 'Play on the radio',
+ onPressed: () => _play(connector, slotTuneId(slot)),
+ ),
+ onTap: open,
+ );
+ }
+
+ Future _play(MeshCoreConnector connector, int id) async {
+ final messenger = ScaffoldMessenger.of(context);
+ final note = describeTunePlay(await connector.playTune(id));
+ if (note == null) return;
+ // Stays until OK: an explanation the user cannot finish reading is not
+ // one.
+ messenger.showSnackBar(
+ SnackBar(
+ content: Text(note),
+ duration: const Duration(days: 1),
+ action: SnackBarAction(label: 'OK', onPressed: () {}),
+ ),
+ );
+ }
+
List _buttonTiles(MeshCoreConnector connector) {
final matrix = connector.buttonMatrix;
if (matrix == null) {
@@ -233,41 +368,3 @@ class _SectionHeader extends StatelessWidget {
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
);
}
-
-/// Persistent error banner. Stays until dismissed rather than flashing, per the
-/// error-visibility rule: an error the user cannot finish reading is not a
-/// surfaced error.
-class _ErrorBanner extends StatelessWidget {
- const _ErrorBanner({required this.message, required this.onDismiss});
- final String message;
- final VoidCallback onDismiss;
-
- @override
- Widget build(BuildContext context) {
- final scheme = Theme.of(context).colorScheme;
- return Container(
- margin: const EdgeInsets.all(12),
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- color: scheme.errorContainer,
- borderRadius: BorderRadius.circular(8),
- ),
- child: Row(
- children: [
- Icon(Icons.error_outline, color: scheme.onErrorContainer),
- const SizedBox(width: 12),
- Expanded(
- child: Text(
- message,
- style: TextStyle(color: scheme.onErrorContainer),
- ),
- ),
- IconButton(
- icon: Icon(Icons.close, color: scheme.onErrorContainer),
- onPressed: onDismiss,
- ),
- ],
- ),
- );
- }
-}
diff --git a/lib/screens/settings/tune_slot_editor_screen.dart b/lib/screens/settings/tune_slot_editor_screen.dart
new file mode 100644
index 00000000..61438d74
--- /dev/null
+++ b/lib/screens/settings/tune_slot_editor_screen.dart
@@ -0,0 +1,305 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:provider/provider.dart';
+
+import '../../connector/meshcore_connector.dart';
+import '../../connector/offband_device_ui.dart';
+import '../../connector/offband_tones.dart';
+import '../../widgets/device_ui_error_banner.dart';
+import '../../widgets/radio_tone.dart';
+
+/// Edit one custom tune slot on the radio (#970).
+///
+/// Opens with the stored tune read back (`SLOT_READ`), so a filled slot can be
+/// changed rather than only replaced. Typing is checked with firmware's own
+/// validator and words before anything is sent, since the radio can only
+/// answer "malformed". Saving is the chunked `SLOT_SET`; the radio replaces
+/// the slot only on the final chunk, so a failed save changes nothing.
+///
+/// English-only, matching [DeviceUiView].
+class TuneSlotEditorScreen extends StatefulWidget {
+ const TuneSlotEditorScreen({
+ super.key,
+ required this.slot,
+ required this.info,
+ });
+
+ final int slot;
+ final TuneSlotInfo info;
+
+ @override
+ State createState() => _TuneSlotEditorScreenState();
+}
+
+class _TuneSlotEditorScreenState extends State {
+ final _controller = TextEditingController();
+
+ /// What the radio holds, once read. Null while reading, or if the read
+ /// failed: then the slot's content is unknown and Play saved is off.
+ String? _saved;
+ bool _reading = false;
+ int _readBytes = 0;
+ bool _saving = false;
+ int _sentBytes = 0;
+ int _sendTotal = 0;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller.addListener(() => setState(() {}));
+ if (widget.info.isEmpty) {
+ _saved = '';
+ } else {
+ WidgetsBinding.instance.addPostFrameCallback((_) => _read());
+ }
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ Future _read() async {
+ final connector = context.read();
+ setState(() => _reading = true);
+ final text = await connector.readTuneSlot(
+ widget.slot,
+ onProgress: (n) {
+ if (mounted) setState(() => _readBytes = n);
+ },
+ );
+ if (!mounted) return;
+ setState(() {
+ _reading = false;
+ _saved = text;
+ if (text != null) _controller.text = text;
+ });
+ }
+
+ bool get _busy => _reading || _saving;
+ String get _text => _controller.text;
+ bool get _dirty => _text != (_saved ?? '');
+
+ Future _save() async {
+ final connector = context.read();
+ final navigator = Navigator.of(context);
+ final text = _text;
+ setState(() {
+ _saving = true;
+ _sentBytes = 0;
+ _sendTotal = text.length;
+ });
+ final ok = await connector.saveTuneSlot(
+ widget.slot,
+ text,
+ onProgress: (sent, total) {
+ if (mounted) setState(() => _sentBytes = sent);
+ },
+ );
+ if (!mounted) return;
+ setState(() {
+ _saving = false;
+ if (ok) _saved = text;
+ });
+ if (!ok) return;
+ // Pop after the rebuild, so PopScope sees the saved state and does not
+ // ask to discard a tune that was just saved.
+ await WidgetsBinding.instance.endOfFrame;
+ if (mounted) navigator.pop();
+ }
+
+ Future _clear() async {
+ final confirmed = await showDialog(
+ context: context,
+ builder: (dialogContext) => AlertDialog(
+ title: Text('Clear slot ${widget.slot + 1}?'),
+ content: const Text(
+ 'The tune is removed from the radio. Events and channels using it '
+ 'stop playing it.',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, true),
+ child: const Text('Clear'),
+ ),
+ ],
+ ),
+ );
+ if (confirmed != true || !mounted) return;
+ _controller.text = '';
+ await _save();
+ }
+
+ Future _playSaved() async {
+ final connector = context.read();
+ final messenger = ScaffoldMessenger.of(context);
+ final note = describeTunePlay(
+ await connector.playTune(slotTuneId(widget.slot)),
+ );
+ if (note == null) return;
+ messenger.showSnackBar(
+ SnackBar(
+ content: Text(note),
+ duration: const Duration(days: 1),
+ action: SnackBarAction(label: 'OK', onPressed: () {}),
+ ),
+ );
+ }
+
+ Future _confirmDiscard() async {
+ final discard = await showDialog(
+ context: context,
+ builder: (dialogContext) => AlertDialog(
+ title: const Text('Discard changes?'),
+ content: const Text('This tune has not been saved to the radio.'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, false),
+ child: const Text('Keep editing'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, true),
+ child: const Text('Discard'),
+ ),
+ ],
+ ),
+ );
+ return discard == true;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final connector = context.watch();
+ final scheme = Theme.of(context).colorScheme;
+ final problem = rtttlProblem(_text);
+ final emptyText = _text.isEmpty;
+ final helper = emptyText
+ ? (_saved == null ? '' : 'Type or paste an RTTTL tune')
+ : problem == RtttlProblem.ok
+ ? 'Plays as "${deriveSlotName(_text)}"'
+ : problem.text;
+ final helperIsProblem = !emptyText && problem != RtttlProblem.ok;
+ final savedHasTune = _saved != null && _saved!.isNotEmpty;
+ final error = connector.deviceUiError;
+ final showError =
+ error != null &&
+ (connector.deviceUiErrorSurface == DeviceUiSurface.tuneSlot ||
+ connector.deviceUiErrorSurface == DeviceUiSurface.tunePlay);
+
+ return PopScope(
+ // Held while a read or save is in flight: leaving would abandon it
+ // halfway, and the radio would keep the old tune.
+ canPop: !_dirty && !_busy,
+ onPopInvokedWithResult: (didPop, _) async {
+ if (didPop || _busy) return;
+ final navigator = Navigator.of(context);
+ if (await _confirmDiscard()) navigator.pop();
+ },
+ child: Scaffold(
+ appBar: AppBar(
+ title: Text('Slot ${widget.slot + 1}'),
+ centerTitle: true,
+ ),
+ body: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ if (showError)
+ DeviceUiErrorBanner(
+ message: error,
+ onDismiss: connector.clearDeviceUiError,
+ margin: const EdgeInsets.only(bottom: 12),
+ ),
+ TextField(
+ controller: _controller,
+ readOnly: _busy,
+ minLines: 4,
+ maxLines: 8,
+ maxLength: maxSlotRtttl,
+ keyboardType: TextInputType.visiblePassword,
+ autocorrect: false,
+ enableSuggestions: false,
+ // RTTTL is plain ASCII on one line; the radio stores bytes.
+ inputFormatters: [
+ FilteringTextInputFormatter.allow(RegExp(r'[\x20-\x7E]')),
+ ],
+ style: const TextStyle(fontFamily: 'monospace'),
+ decoration: InputDecoration(
+ labelText: 'RTTTL',
+ border: const OutlineInputBorder(),
+ helperText: helperIsProblem ? null : helper,
+ errorText: helperIsProblem ? helper : null,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Wrap(
+ alignment: WrapAlignment.end,
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ TextButton(
+ onPressed: _busy || !savedHasTune ? null : _clear,
+ style: TextButton.styleFrom(foregroundColor: scheme.error),
+ child: const Text('Clear slot'),
+ ),
+ OutlinedButton.icon(
+ onPressed: _busy || !savedHasTune || _dirty
+ ? null
+ : _playSaved,
+ icon: const Icon(Icons.play_circle_outline),
+ label: const Text('Play saved'),
+ ),
+ FilledButton(
+ onPressed:
+ _busy ||
+ !_dirty ||
+ emptyText ||
+ problem != RtttlProblem.ok
+ ? null
+ : _save,
+ child: const Text('Save'),
+ ),
+ ],
+ ),
+ if (_reading) ...[
+ const SizedBox(height: 16),
+ LinearProgressIndicator(
+ value: widget.info.length == 0
+ ? null
+ : _readBytes / widget.info.length,
+ ),
+ const SizedBox(height: 6),
+ Text(
+ 'Reading from the radio: $_readBytes of '
+ '${widget.info.length} characters',
+ ),
+ ],
+ if (_saving) ...[
+ const SizedBox(height: 16),
+ LinearProgressIndicator(
+ value: _sendTotal == 0 ? null : _sentBytes / _sendTotal,
+ ),
+ const SizedBox(height: 6),
+ Text(
+ _sendTotal == 0
+ ? 'Clearing the slot'
+ : 'Sending to the radio: $_sentBytes of $_sendTotal '
+ 'characters',
+ ),
+ ],
+ const SizedBox(height: 16),
+ Text(
+ 'Play saved plays what is on the radio, so it is off while there '
+ 'are unsaved changes.',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index 302d2304..9c1d4414 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -25,6 +25,8 @@ 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/badge_battery_view.dart';
+import 'settings/badge_screen_view.dart';
import 'settings/device_ui_view.dart';
import 'settings/location_view.dart';
import '../helpers/device_name_validator.dart';
@@ -410,6 +412,18 @@ class _SettingsScreenState extends State {
Widget _radioStatsPane(BuildContext context) =>
const CompanionRadioStatsBody();
+ void _pushBadgePage(BuildContext context, String title, Widget body) {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => Scaffold(
+ appBar: AppBar(title: Text(title), centerTitle: true),
+ body: body,
+ ),
+ ),
+ );
+ }
+
Widget _identityPane(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch();
@@ -462,6 +476,41 @@ class _SettingsScreenState extends State {
},
),
],
+ // The badge's own screen and battery settings (#987), beside
+ // Button actions (owner decision 2026-10-25). Strictly on the
+ // 0x40 bit: no bit, no rows, no command.
+ if (connector.supportsScreenSettings) ...[
+ const Divider(height: 1),
+ ListTile(
+ leading: const Icon(Icons.monitor),
+ title: Text(l10n.settings_badgeScreen),
+ subtitle: Text(l10n.settings_badgeScreenSubtitle),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () => _pushBadgePage(
+ context,
+ l10n.settings_badgeScreen,
+ const BadgeScreenView(),
+ ),
+ ),
+ const Divider(height: 1),
+ ListTile(
+ leading: const Icon(Icons.battery_std),
+ title: Text(l10n.settings_badgeBattery),
+ subtitle: Text(
+ connector.screenSettings == null
+ ? l10n.settings_badgeBatteryReading
+ : l10n.settings_badgeBatterySubtitle(
+ connector.screenSettings!.batteryFullMv,
+ ),
+ ),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () => _pushBadgePage(
+ context,
+ l10n.settings_badgeBattery,
+ const BadgeBatteryView(),
+ ),
+ ),
+ ],
if (connector.selfPublicKey != null) ...[
const Divider(height: 1),
Padding(
diff --git a/lib/widgets/device_ui_error_banner.dart b/lib/widgets/device_ui_error_banner.dart
new file mode 100644
index 00000000..1aa52a4d
--- /dev/null
+++ b/lib/widgets/device_ui_error_banner.dart
@@ -0,0 +1,45 @@
+import 'package:flutter/material.dart';
+
+/// The radio's refusal of a device-UI write, shown until dismissed rather than
+/// flashed: an error the user cannot finish reading is not a surfaced error.
+class DeviceUiErrorBanner extends StatelessWidget {
+ const DeviceUiErrorBanner({
+ super.key,
+ required this.message,
+ required this.onDismiss,
+ this.margin = const EdgeInsets.all(12),
+ });
+
+ final String message;
+ final VoidCallback onDismiss;
+ final EdgeInsetsGeometry margin;
+
+ @override
+ Widget build(BuildContext context) {
+ final scheme = Theme.of(context).colorScheme;
+ return Container(
+ margin: margin,
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: scheme.errorContainer,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Row(
+ children: [
+ Icon(Icons.error_outline, color: scheme.onErrorContainer),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ message,
+ style: TextStyle(color: scheme.onErrorContainer),
+ ),
+ ),
+ IconButton(
+ icon: Icon(Icons.close, color: scheme.onErrorContainer),
+ onPressed: onDismiss,
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/radio_tone.dart b/lib/widgets/radio_tone.dart
new file mode 100644
index 00000000..5edf75e9
--- /dev/null
+++ b/lib/widgets/radio_tone.dart
@@ -0,0 +1,332 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../connector/meshcore_connector.dart';
+import '../connector/offband_device_ui.dart';
+import '../connector/offband_tones.dart';
+import '../models/channel.dart';
+
+/// The radio's tune picker (#971, #986), shared by the six event tones and a
+/// channel's own tone, so every entry point offers the same list the badge's
+/// picker does. A channel's "Radio tone" row lives here too, reachable from
+/// the Channels sheet and the channel chat menu, so the two cannot drift.
+///
+/// English-only, matching [DeviceUiView] which hosts the event rows.
+
+IconData tuneIcon(int? id) => id == null
+ ? Icons.sync
+ : id == tuneSilent
+ ? Icons.volume_off_outlined
+ : Icons.music_note_outlined;
+
+/// The radio's reason a preview made no sound, or null when it played.
+String? describeTunePlay(TunePlayResult? result) => switch (result) {
+ TunePlayResult.played => null,
+ TunePlayResult.busy =>
+ 'The radio is playing a notification. Try again in a moment.',
+ TunePlayResult.soundOff =>
+ 'Sound is off on this radio. Set the scope to All or Self to hear it.',
+ null => 'The radio did not play that tune.',
+};
+
+/// A tune choice from [showTunePickerDialog].
+sealed class TuneChoice {
+ const TuneChoice();
+}
+
+/// "Default": no choice, follow the radio's tone. Offered only to a channel.
+class DefaultTuneChoice extends TuneChoice {
+ const DefaultTuneChoice();
+}
+
+class TuneIdChoice extends TuneChoice {
+ const TuneIdChoice(this.id);
+ final int id;
+}
+
+/// Show the picker. [current] is the row to mark; null marks Default. Returns
+/// the choice, or null when the user cancels.
+///
+/// The play button auditions on the radio and never chooses; tapping a name
+/// chooses (owner decision 2026-10-25). Only what firmware accepts is listed:
+/// named built-ins and filled slots.
+Future showTunePickerDialog(
+ BuildContext context, {
+ required String title,
+ String? description,
+ required int? current,
+ String? defaultSubtitle,
+ bool withDefault = false,
+}) {
+ return showDialog(
+ context: context,
+ builder: (dialogContext) => _TunePickerDialog(
+ title: title,
+ description: description,
+ current: current,
+ defaultSubtitle: defaultSubtitle,
+ withDefault: withDefault,
+ ),
+ );
+}
+
+class _TunePickerDialog extends StatefulWidget {
+ const _TunePickerDialog({
+ required this.title,
+ required this.description,
+ required this.current,
+ required this.defaultSubtitle,
+ required this.withDefault,
+ });
+
+ final String title;
+ final String? description;
+ final int? current;
+ final String? defaultSubtitle;
+ final bool withDefault;
+
+ @override
+ State<_TunePickerDialog> createState() => _TunePickerDialogState();
+}
+
+class _TunePickerDialogState extends State<_TunePickerDialog> {
+ /// A preview's refusal, shown inside the dialog: a snackbar would sit
+ /// behind the dialog's scrim.
+ String? _playNote;
+
+ Future _play(MeshCoreConnector connector, int id) async {
+ setState(() => _playNote = null);
+ final result = await connector.playTune(id);
+ if (!mounted) return;
+ setState(() => _playNote = describeTunePlay(result));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final scheme = Theme.of(context).colorScheme;
+ return Consumer(
+ builder: (context, connector, _) {
+ final slots = connector.tuneSlots;
+ final ids = offeredTuneIds(slots);
+ final builtins = ids.where((id) => !isSlotTuneId(id)).toList();
+ final custom = ids.where(isSlotTuneId).toList();
+
+ Widget row(int id) {
+ final selected = id == widget.current;
+ return ListTile(
+ leading: Icon(tuneIcon(id)),
+ title: Text(tuneLabel(id, slots) ?? 'Tune $id'),
+ subtitle: selected ? const Text('Current') : null,
+ selected: selected,
+ trailing: id == tuneSilent
+ ? null
+ : IconButton(
+ icon: const Icon(Icons.play_circle_outline),
+ color: scheme.primary,
+ tooltip: 'Play on the radio',
+ onPressed: () => _play(connector, id),
+ ),
+ onTap: () => Navigator.pop(context, TuneIdChoice(id)),
+ );
+ }
+
+ return AlertDialog(
+ title: Text(widget.title),
+ contentPadding: const EdgeInsets.symmetric(vertical: 8),
+ content: SizedBox(
+ width: double.maxFinite,
+ child: ListView(
+ shrinkWrap: true,
+ children: [
+ if (widget.description != null)
+ Padding(
+ padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
+ child: Text(widget.description!),
+ ),
+ if (_playNote != null)
+ Padding(
+ padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
+ child: Text(
+ _playNote!,
+ style: TextStyle(color: scheme.error),
+ ),
+ ),
+ if (widget.withDefault) ...[
+ ListTile(
+ leading: Icon(tuneIcon(null)),
+ title: const Text('Default'),
+ subtitle: widget.defaultSubtitle == null
+ ? null
+ : Text(widget.defaultSubtitle!),
+ selected: widget.current == null,
+ trailing: widget.current == null
+ ? const Icon(Icons.check)
+ : null,
+ onTap: () =>
+ Navigator.pop(context, const DefaultTuneChoice()),
+ ),
+ const Divider(height: 1),
+ ],
+ for (final id in builtins) row(id),
+ if (custom.isNotEmpty) ...[
+ Padding(
+ padding: const EdgeInsets.fromLTRB(24, 12, 24, 4),
+ child: Text(
+ 'Your tunes',
+ style: Theme.of(context).textTheme.labelMedium,
+ ),
+ ),
+ for (final id in custom) row(id),
+ ],
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ ],
+ );
+ },
+ );
+ }
+}
+
+/// What a channel's Default plays: the Channel event's effective tune, when
+/// the radio has reported it.
+String _defaultSubtitle(MeshCoreConnector connector) {
+ final effective = connector.eventTone(ToneEvent.channel)?.effective;
+ final name = effective == null
+ ? null
+ : tuneLabel(effective, connector.tuneSlots);
+ return name == null
+ ? 'The radio\'s channel tone'
+ : '$name, the radio\'s channel tone';
+}
+
+/// The subtitle for a channel's Radio tone row.
+String channelRadioToneLabel(MeshCoreConnector connector, int channel) {
+ final tune = connector.channelTuneFor(channel);
+ if (tune == null) return 'Reading from the radio';
+ if (tune.inherits) return 'Default';
+ return tuneLabel(tune.tuneId!, connector.tuneSlots) ?? 'Tune ${tune.tuneId}';
+}
+
+/// Read what the Radio tone row and its picker need.
+void requestChannelRadioTone(MeshCoreConnector connector, int channel) {
+ connector.requestChannelTune(channel);
+ connector.requestEventTones();
+ connector.requestTuneSlots();
+}
+
+/// Show the radio's refusal of a write to [surface] as a snackbar that stays
+/// until dismissed, for screens with no banner of their own. Watches for a few
+/// seconds after the write; a confirmed write shows nothing.
+void showRefusalWhenItArrives(
+ BuildContext context,
+ MeshCoreConnector connector,
+ DeviceUiSurface surface,
+) {
+ final messenger = ScaffoldMessenger.maybeOf(context);
+ if (messenger == null) return;
+ late final VoidCallback listener;
+ listener = () {
+ final error = connector.deviceUiError;
+ if (connector.deviceUiErrorSurface != surface || error == null) return;
+ connector.removeListener(listener);
+ messenger.showSnackBar(
+ SnackBar(
+ content: Text(error),
+ duration: const Duration(days: 1),
+ action: SnackBarAction(
+ label: 'OK',
+ onPressed: connector.clearDeviceUiError,
+ ),
+ ),
+ );
+ };
+ connector.addListener(listener);
+ Future.delayed(
+ const Duration(seconds: 5),
+ () => connector.removeListener(listener),
+ );
+}
+
+Future showChannelRadioToneDialog(
+ BuildContext context,
+ Channel channel,
+) async {
+ final connector = context.read();
+ final tune = connector.channelTuneFor(channel.index);
+ final choice = await showTunePickerDialog(
+ context,
+ title: 'Radio tone',
+ description: channel.name,
+ current: tune?.tuneId,
+ defaultSubtitle: _defaultSubtitle(connector),
+ withDefault: true,
+ );
+ if (choice != null && context.mounted) {
+ showRefusalWhenItArrives(context, connector, DeviceUiSurface.channelTune);
+ }
+ switch (choice) {
+ case DefaultTuneChoice():
+ await connector.setChannelTune(
+ channel.index,
+ const ChannelTune.inherit(),
+ );
+ case TuneIdChoice(:final id):
+ await connector.setChannelTune(channel.index, ChannelTune.tune(id));
+ case null:
+ break;
+ }
+}
+
+/// The channel sheet's Radio tone row. Reads the channel's tune when shown, so
+/// its subtitle is the radio's answer. Shown only with caps byte 2 `0x10`.
+class ChannelRadioToneTile extends StatefulWidget {
+ const ChannelRadioToneTile({
+ super.key,
+ required this.channel,
+ required this.onTap,
+ });
+
+ final Channel channel;
+ final VoidCallback onTap;
+
+ @override
+ State createState() => _ChannelRadioToneTileState();
+}
+
+class _ChannelRadioToneTileState extends State {
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ final c = context.read();
+ if (c.supportsTunes) requestChannelRadioTone(c, widget.channel.index);
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final connector = context.watch();
+ if (!connector.supportsTunes) return const SizedBox.shrink();
+ final tune = connector.channelTuneFor(widget.channel.index);
+ return ListTile(
+ leading: Icon(tune == null ? Icons.sync : tuneIcon(tune.tuneId)),
+ title: const Text('Radio tone'),
+ subtitle: Text(channelRadioToneLabel(connector, widget.channel.index)),
+ onTap: widget.onTap,
+ );
+ }
+}
+
+/// Whether [surface]'s refusal belongs on a tone screen.
+bool isToneSurface(DeviceUiSurface? surface) =>
+ surface == DeviceUiSurface.channelTune ||
+ surface == DeviceUiSurface.eventTone ||
+ surface == DeviceUiSurface.tunePlay ||
+ surface == DeviceUiSurface.tuneSlot;
diff --git a/test/connector/channel_tune_connector_test.dart b/test/connector/channel_tune_connector_test.dart
index 08caf6c7..577494c0 100644
--- a/test/connector/channel_tune_connector_test.dart
+++ b/test/connector/channel_tune_connector_test.dart
@@ -61,7 +61,7 @@ void main() {
await connector.requestChannelTune(0);
await connector.setChannelTune(0, ChannelTune.tune(0));
- expect(connector.supportsChannelTune, isFalse);
+ expect(connector.supportsTunes, isFalse);
expect(sentTune(), isFalse);
});
diff --git a/test/connector/offband_device_ui_test.dart b/test/connector/offband_device_ui_test.dart
index d560a21c..81d91d01 100644
--- a/test/connector/offband_device_ui_test.dart
+++ b/test/connector/offband_device_ui_test.dart
@@ -211,13 +211,21 @@ void main() {
test('the new bits collide with no allocated caps-byte-2 bit', () {
// Firmware allocates 0x01 scope, 0x02 matrix, 0x04 indicators, 0x08
- // packet hash (OffbandConfigProtocol.h on firmware-base).
+ // packet hash (OffbandConfigProtocol.h on firmware-base), and
+ // 3e788e2a adds 0x10 TUNES, 0x20 GPS mode, 0x40 SCREEN_SETTINGS.
const allocated = 0x01 | 0x02 | 0x04 | 0x08;
- expect(offbandCap2GpsMode & allocated, equals(0));
- expect(offbandCap2ChannelTune & allocated, equals(0));
- expect(offbandCap2GpsMode & offbandCap2ChannelTune, equals(0));
+ const added = [
+ offbandCap2Tunes,
+ offbandCap2GpsMode,
+ offbandCap2ScreenSettings,
+ ];
+ for (final bit in added) {
+ expect(bit & allocated, equals(0));
+ expect(added.where((b) => b & bit != 0).length, equals(1));
+ }
+ expect(offbandCap2Tunes, equals(0x10));
expect(offbandCap2GpsMode, equals(0x20));
- expect(offbandCap2ChannelTune, equals(0x10));
+ expect(offbandCap2ScreenSettings, equals(0x40));
});
});
diff --git a/test/connector/offband_screen_settings_test.dart b/test/connector/offband_screen_settings_test.dart
new file mode 100644
index 00000000..9074967f
--- /dev/null
+++ b/test/connector/offband_screen_settings_test.dart
@@ -0,0 +1,146 @@
+// #987: the badge's screen and battery settings over 0xC5 0x13 to 0x17, and
+// the time zone table, against meshcore-firmware 3e788e2a.
+
+import 'dart:typed_data';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:meshcore_open/connector/offband_screen_settings.dart';
+import 'package:meshcore_open/models/badge_time_zones.dart';
+
+void main() {
+ group('frames', () {
+ test('get and each set encode as documented', () {
+ expect(buildScreenSettingsGetFrame(), equals([0xC5, 0x13]));
+ expect(
+ buildTextSizeSetFrame(BadgeTextTarget.messages, BadgeTextSize.small),
+ equals([0xC5, 0x14, 1, 2]),
+ );
+ expect(buildScreenOffSetFrame(300), equals([0xC5, 0x15, 0x2C, 0x01]));
+ expect(buildTimeZoneSetFrame(2), equals([0xC5, 0x16, 2]));
+ expect(buildBatteryFullSetFrame(pin: true), equals([0xC5, 0x17, 1]));
+ expect(buildBatteryFullSetFrame(pin: false), equals([0xC5, 0x17, 0]));
+ });
+ });
+
+ group('reply', () {
+ test('all fourteen bytes, little-endian', () {
+ final r = parseScreenSettingsReply(
+ Uint8List.fromList([
+ 0xC5, 0x15, //
+ 0, 1, // nav large, messages medium
+ 0x3C, 0x00, // stored 60 s
+ 0x3C, 0x00, // waits 60 s
+ 2, // Eastern
+ 0x36, 0x10, // 4150 mV stored
+ 0x36, 0x10, // 4150 mV in use
+ 0, // learned
+ ]),
+ )!;
+ final s = r.screen!;
+ expect(s.navText, BadgeTextSize.large);
+ expect(s.messageText, BadgeTextSize.medium);
+ expect(s.screenOffStored, 60);
+ expect(s.screenOffIsDefault, isFalse);
+ expect(s.timeZone, 2);
+ expect(s.batteryFullStored, 4150);
+ expect(s.batteryPinned, isFalse);
+ });
+
+ test('a short reply is never shown as the radio\'s state', () {
+ expect(
+ parseScreenSettingsReply(Uint8List.fromList([0xC5, 0x13, 0, 1])),
+ isNull,
+ );
+ });
+
+ test('screen off labels', () {
+ expect(screenOffSteps.map(screenOffLabel), [
+ '15 s',
+ '30 s',
+ '1 m',
+ '2 m',
+ '5 m',
+ ]);
+ });
+ });
+
+ group('time zones', () {
+ test('the table matches firmware: 41 entries, indices pinned', () {
+ expect(badgeTimeZones.length, 41);
+ expect(badgeTimeZones[0].name, 'not set');
+ expect(badgeTimeZones[2].name, 'Eastern');
+ expect(badgeTimeZones[12].name, 'UTC-12');
+ expect(badgeTimeZones.last.name, 'UTC+9:30');
+ });
+
+ test('US daylight time, both edges', () {
+ // 2026: DST runs Mar 8 07:00 UTC to Nov 1 06:00 UTC for Eastern.
+ expect(timeZoneOffsetMinutes(2, DateTime.utc(2026, 3, 8, 6, 59)), -300);
+ expect(timeZoneOffsetMinutes(2, DateTime.utc(2026, 3, 8, 7)), -240);
+ expect(timeZoneOffsetMinutes(2, DateTime.utc(2026, 11, 1, 5, 59)), -240);
+ expect(timeZoneOffsetMinutes(2, DateTime.utc(2026, 11, 1, 6)), -300);
+ // Arizona keeps standard time all year.
+ expect(timeZoneOffsetMinutes(5, DateTime.utc(2026, 7, 1)), -420);
+ });
+
+ test('EU summer time, both edges', () {
+ // 2026: last Sundays are Mar 29 and Oct 25, at 01:00 UTC.
+ expect(timeZoneOffsetMinutes(10, DateTime.utc(2026, 3, 29, 0, 59)), 60);
+ expect(timeZoneOffsetMinutes(10, DateTime.utc(2026, 3, 29, 1)), 120);
+ expect(timeZoneOffsetMinutes(10, DateTime.utc(2026, 10, 25, 1)), 60);
+ });
+
+ test('not set and unknown indices have no offset', () {
+ expect(timeZoneOffsetMinutes(0, DateTime.utc(2026)), isNull);
+ expect(timeZoneOffsetMinutes(99, DateTime.utc(2026)), isNull);
+ expect(timeZoneName(99), 'Zone 99');
+ });
+
+ test('offset labels', () {
+ expect(formatUtcOffset(-240), 'UTC-4');
+ expect(formatUtcOffset(330), 'UTC+5:30');
+ expect(formatUtcOffset(0), 'UTC+0');
+ });
+
+ // A phone whose offset follows the US Eastern rule.
+ Duration eastern(DateTime local) =>
+ Duration(minutes: timeZoneOffsetMinutes(2, local.toUtc())!);
+
+ test('a phone on Eastern is offered Eastern, not UTC-4', () {
+ expect(
+ suggestTimeZone(now: DateTime(2026, 10, 25, 12), offsetAt: eastern),
+ 2,
+ );
+ });
+
+ test('a phone with no daylight rule gets the fixed offset', () {
+ expect(
+ suggestTimeZone(
+ now: DateTime(2026, 10, 25, 12),
+ offsetAt: (_) => const Duration(minutes: 330),
+ ),
+ 39,
+ );
+ });
+
+ test('a phone on UTC gets UTC, not the UK', () {
+ expect(
+ suggestTimeZone(
+ now: DateTime(2026, 10, 25, 12),
+ offsetAt: (_) => Duration.zero,
+ ),
+ 1,
+ );
+ });
+
+ test('an offset with no zone suggests nothing', () {
+ expect(
+ suggestTimeZone(
+ now: DateTime(2026, 10, 25, 12),
+ offsetAt: (_) => const Duration(minutes: 345),
+ ),
+ isNull,
+ );
+ });
+ });
+}
diff --git a/test/connector/offband_tones_test.dart b/test/connector/offband_tones_test.dart
new file mode 100644
index 00000000..a593cdc6
--- /dev/null
+++ b/test/connector/offband_tones_test.dart
@@ -0,0 +1,139 @@
+// #970, #986: the tune codec over 0xC5, against the contract in
+// meshcore-firmware 3e788e2a OffbandConfigProtocol.h, and the ports of its
+// RTTTL validator and slot-name rule.
+
+import 'dart:typed_data';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:meshcore_open/connector/offband_tones.dart';
+
+void main() {
+ group('frames', () {
+ test('event tone get and set', () {
+ expect(buildEventToneGetFrame(ToneEvent.dm), equals([0xC5, 0x0F, 0]));
+ expect(
+ buildEventToneSetFrame(ToneEvent.sent, 9),
+ equals([0xC5, 0x10, 5, 9]),
+ );
+ });
+
+ test('play, slot get and slot read', () {
+ expect(buildTunePlayFrame(8), equals([0xC5, 0x11, 8]));
+ expect(buildSlotGetFrame(7), equals([0xC5, 0x09, 7]));
+ expect(buildSlotReadFrame(2, 15), equals([0xC5, 0x12, 2, 15]));
+ });
+
+ test('slot set carries offset, more and the bytes', () {
+ expect(
+ buildSlotSetFrame(1, 15, true, [0x61, 0x62]),
+ equals([0xC5, 0x0A, 1, 15, 1, 0x61, 0x62]),
+ );
+ // The empty final chunk at offset 0 is how a slot is cleared.
+ expect(buildSlotSetFrame(1, 0, false, []), equals([0xC5, 0x0A, 1, 0, 0]));
+ });
+
+ test('a slot past the eight is refused before it is sent', () {
+ expect(() => buildSlotGetFrame(8), throwsArgumentError);
+ expect(() => buildSlotReadFrame(-1, 0), throwsArgumentError);
+ });
+ });
+
+ group('replies', () {
+ Uint8List f(List b) => Uint8List.fromList(b);
+
+ test('event tone keeps stored and effective apart', () {
+ final r = parseTuneReply(f([0xC5, 0x0F, 0, 0xFF, 1]))!;
+ expect(r.eventTone!.event, ToneEvent.dm);
+ expect(r.eventTone!.state.isRadioDefault, isTrue);
+ expect(r.eventTone!.state.effective, 1);
+ });
+
+ test('an unknown event is an error, not a guess', () {
+ expect(parseTuneReply(f([0xC5, 0x10, 9, 1, 1]))!.isError, isTrue);
+ });
+
+ test('slot get: length and name, and an empty slot', () {
+ final full = parseTuneReply(
+ f([0xC5, 0x09, 0, 142, ...'Mario'.codeUnits]),
+ );
+ expect(full!.tuneSlot!.info.length, 142);
+ expect(full.tuneSlot!.info.name, 'Mario');
+ final empty = parseTuneReply(f([0xC5, 0x09, 3, 0]));
+ expect(empty!.tuneSlot!.info.isEmpty, isTrue);
+ });
+
+ test('slot read chunk and play result', () {
+ final c = parseTuneReply(f([0xC5, 0x12, 1, 15, 1, 0x61, 0x62]))!;
+ expect(c.slotChunk!.offset, 15);
+ expect(c.slotChunk!.more, isTrue);
+ expect(c.slotChunk!.bytes, equals([0x61, 0x62]));
+ final p = parseTuneReply(f([0xC5, 0x11, 8, 2]))!;
+ expect(p.tunePlay!.result, TunePlayResult.soundOff);
+ });
+
+ test('truncated replies are dropped', () {
+ expect(parseTuneReply(f([0xC5, 0x0F, 0, 1])), isNull);
+ expect(parseTuneReply(f([0xC5, 0x12, 1, 0])), isNull);
+ expect(parseTuneReply(f([0xC5, 0x11, 8])), isNull);
+ });
+ });
+
+ group('the picker rule', () {
+ test('named built-ins plus filled slots, never id 7 or empty slots', () {
+ final slots = {
+ 0: const TuneSlotInfo(length: 142, name: 'Mario'),
+ 1: const TuneSlotInfo(length: 0, name: ''),
+ };
+ expect(offeredTuneIds(slots), equals([0, 1, 2, 3, 4, 5, 6, 8]));
+ });
+
+ test('labels', () {
+ final slots = {
+ 0: const TuneSlotInfo(length: 142, name: 'Mario'),
+ 4: const TuneSlotInfo(length: 20, name: ''),
+ };
+ expect(tuneLabel(2, slots), 'Kerplop');
+ expect(tuneLabel(7, slots), isNull);
+ expect(tuneLabel(8, slots), 'Mario');
+ expect(tuneLabel(12, slots), 'Slot 5');
+ expect(tuneLabel(9, slots), isNull);
+ });
+ });
+
+ group('rtttlProblem, ported from firmware', () {
+ final cases = {
+ 'Mario:d=4,o=5,b=100:16e6,16e6': RtttlProblem.ok,
+ 'MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7': RtttlProblem.ok,
+ 'ack:d=32,o=8,b=120:c': RtttlProblem.ok,
+ '': RtttlProblem.empty,
+ 'no colon here': RtttlProblem.missingColon,
+ ':d=4:c': RtttlProblem.noName,
+ 'x:d=:c': RtttlProblem.badDefault,
+ 'x:o=55:c': RtttlProblem.badDefault,
+ 'Doorbell:d=4,o=5,b=0:8e6': RtttlProblem.zeroTempo,
+ 'x:b=:c': RtttlProblem.zeroTempo,
+ 'x:d=4:': RtttlProblem.noNotes,
+ 'x:b8,c': RtttlProblem.ambiguousNote,
+ 'x:c:d': RtttlProblem.strayColon,
+ 'x: d=4,o=5,b=100:c': RtttlProblem.strayColon,
+ };
+ cases.forEach((tune, want) {
+ test('"$tune" is ${want.name}', () {
+ expect(rtttlProblem(tune), want);
+ });
+ });
+
+ test('every problem has firmware\'s words', () {
+ expect(RtttlProblem.zeroTempo.text, 'b= must be 1 or more');
+ expect(RtttlProblem.missingColon.text, 'Needs name:notes');
+ });
+ });
+
+ group('deriveSlotName', () {
+ test('the text before the first colon, trimmed and capped at 12', () {
+ expect(deriveSlotName(' Mario :d=4:c'), 'Mario');
+ expect(deriveSlotName('ABCDEFGHIJKLMNOP:c'), 'ABCDEFGHIJKL');
+ expect(deriveSlotName('no colon'), '');
+ });
+ });
+}
diff --git a/test/connector/tunes_connector_test.dart b/test/connector/tunes_connector_test.dart
new file mode 100644
index 00000000..354eaf29
--- /dev/null
+++ b/test/connector/tunes_connector_test.dart
@@ -0,0 +1,323 @@
+// #970, #986, #987: the connector side of tunes and screen settings over 0xC5.
+// Pins the gates (no bit, no frame), that state follows only the radio's
+// replies, the chunked slot read and save, the one restart a displaced save
+// gets, and that refusals land on the right surface.
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:typed_data';
+
+import 'package:drift/native.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:meshcore_open/connector/meshcore_connector.dart';
+import 'package:meshcore_open/connector/offband_device_ui.dart';
+import 'package:meshcore_open/connector/offband_tones.dart';
+import 'package:meshcore_open/storage/drift/blob_store.dart';
+import 'package:meshcore_open/storage/drift/offband_database.dart';
+import 'package:meshcore_open/storage/prefs_manager.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// RESP_CODE_DEVICE_INFO from an Offband radio, caps byte 2 at offset 84.
+List deviceInfo({required int caps2}) {
+ final v = ascii.encode('1.5.0-1.17.0');
+ final f = Uint8List(85);
+ f[0] = 13;
+ f[1] = 22;
+ f.setRange(60, 60 + v.length, v);
+ f[84] = caps2;
+ return f;
+}
+
+void main() {
+ late OffbandDatabase db;
+ late MeshCoreConnector connector;
+ late List> sent;
+
+ /// Replies the fake radio sends for each frame the client writes.
+ List> Function(List frame)? radio;
+
+ setUp(() async {
+ SharedPreferences.setMockInitialValues({});
+ PrefsManager.reset();
+ await PrefsManager.initialize();
+ db = OffbandDatabase(NativeDatabase.memory());
+ BlobStore.overrideForTest(BlobStore(db));
+ sent = [];
+ radio = null;
+ connector = MeshCoreConnector();
+ connector.sendFrameOverrideForTest = (data) {
+ final frame = data.toList();
+ sent.add(frame);
+ final replies = radio?.call(frame) ?? const [];
+ // Replies arrive after the write returns, as they would over a link.
+ scheduleMicrotask(() {
+ for (final r in replies) {
+ connector.handleFrameForTest(r);
+ }
+ });
+ };
+ connector.setConnectedForTest();
+ });
+
+ tearDown(() async {
+ BlobStore.clearTestOverride();
+ await db.close();
+ });
+
+ bool sentSub(Set subs) => sent.any(
+ (f) => f.length >= 2 && f[0] == cmdOffbandDeviceUi && subs.contains(f[1]),
+ );
+
+ group('gates', () {
+ test('no 0x10: no tune frame of any kind', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x01));
+ sent.clear();
+ await connector.requestEventTones();
+ await connector.setEventTone(ToneEvent.dm, 1);
+ await connector.requestTuneSlots();
+ expect(await connector.playTune(1), isNull);
+ expect(await connector.readTuneSlot(0), isNull);
+ expect(await connector.saveTuneSlot(0, 'x:d=4:c'), isFalse);
+ expect(sentSub({0x09, 0x0A, 0x0F, 0x10, 0x11, 0x12}), isFalse);
+ });
+
+ test('no 0x40: no screen frame, and none on connect', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x10));
+ await connector.setTimeZone(2);
+ await connector.setScreenOff(60);
+ expect(sentSub({0x13, 0x14, 0x15, 0x16, 0x17}), isFalse);
+ });
+
+ test('with 0x40 the settings are read on connect', () {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x40));
+ expect(sent, contains(equals([0xC5, 0x13])));
+ });
+ });
+
+ group('event tones', () {
+ test('reads all six, and a write shows only once confirmed', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x10));
+ sent.clear();
+ await connector.requestEventTones();
+ expect(sent, [
+ for (var ev = 0; ev < 6; ev++) [0xC5, 0x0F, ev],
+ ]);
+
+ await connector.setEventTone(ToneEvent.room, 8);
+ expect(connector.eventTone(ToneEvent.room), isNull);
+ connector.handleFrameForTest([0xC5, 0x10, 2, 8, 8]);
+ expect(connector.eventTone(ToneEvent.room)!.stored, 8);
+ });
+
+ test('a refused event tone is the event tone surface\'s', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x10));
+ await connector.setEventTone(ToneEvent.dm, 9);
+ connector.handleFrameForTest([0xC5, 0x7F, 0x05]);
+ expect(connector.deviceUiErrorSurface, DeviceUiSurface.eventTone);
+ expect(connector.eventTone(ToneEvent.dm), isNull);
+ });
+ });
+
+ group('preview', () {
+ test('returns the radio\'s result', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x10));
+ radio = (f) => [
+ [0xC5, 0x11, f[2], 2],
+ ];
+ expect(await connector.playTune(3), TunePlayResult.soundOff);
+ });
+
+ test('a refused id comes back null and is shown', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x10));
+ radio = (f) => [
+ [0xC5, 0x7F, 0x05],
+ ];
+ expect(await connector.playTune(12), isNull);
+ expect(connector.deviceUiErrorSurface, DeviceUiSurface.tunePlay);
+ });
+ });
+
+ group('slot read', () {
+ test('follows more until the radio says done', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x10));
+ final tune = ascii.encode('Mario:d=4,o=5,b=100:16e6,16e6,32p');
+ radio = (f) {
+ final offset = f[3];
+ final end = (offset + 10).clamp(0, tune.length);
+ return [
+ [
+ 0xC5,
+ 0x12,
+ f[2],
+ offset,
+ end < tune.length ? 1 : 0,
+ ...tune.sublist(offset, end),
+ ],
+ ];
+ };
+ final progress = [];
+ final text = await connector.readTuneSlot(1, onProgress: progress.add);
+ expect(text, ascii.decode(tune));
+ expect(progress.last, tune.length);
+ expect(connector.tuneSlotTransferActive, isFalse);
+ });
+
+ test('an empty slot reads as empty text', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x10));
+ radio = (f) => [
+ [0xC5, 0x12, f[2], 0, 0],
+ ];
+ expect(await connector.readTuneSlot(4), '');
+ });
+
+ test('a chunk that says more but carries nothing stops the read', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x10));
+ radio = (f) => [
+ [0xC5, 0x12, f[2], f[3], 1],
+ ];
+ expect(await connector.readTuneSlot(0), isNull);
+ expect(connector.deviceUiErrorSurface, DeviceUiSurface.tuneSlot);
+ });
+ });
+
+ group('slot save', () {
+ /// A radio that assembles pushes the way firmware does, optionally
+ /// displacing the first push after its first chunk.
+ List> Function(List) assembler({bool displaceOnce = false}) {
+ var held = [];
+ var displaced = !displaceOnce;
+ return (f) {
+ if (f[1] == 0x09) {
+ return [
+ [0xC5, 0x09, f[2], held.length, ...ascii.encode('Mario')],
+ ];
+ }
+ if (f[1] != 0x0A) return const [];
+ final offset = f[3];
+ final more = f[4] != 0;
+ if (offset == 0) held = [];
+ if (offset != held.length) {
+ return [
+ [0xC5, 0x7F, 0x07, held.length],
+ ];
+ }
+ held.addAll(f.sublist(5));
+ if (!displaced && more) {
+ displaced = true;
+ held = [];
+ return [
+ [0xC5, 0x7F, 0x07, 0],
+ ];
+ }
+ return [
+ [0xC5, 0x0A, f[2], more ? held.length : 0],
+ ];
+ };
+ }
+
+ test('sends chunks with running offsets and commits on the last', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x10));
+ radio = assembler();
+ sent.clear();
+ final tune = 'Mario:d=4,o=5,b=100:${List.filled(40, '8e6').join(',')}';
+ final ok = await connector.saveTuneSlot(0, tune);
+ expect(ok, isTrue);
+
+ final pushes = sent.where((f) => f[1] == 0x0A).toList();
+ expect(pushes.length, greaterThan(1));
+ var expectOffset = 0;
+ for (final p in pushes) {
+ expect(p[3], expectOffset);
+ expectOffset += p.length - 5;
+ }
+ expect(pushes.last[4], 0);
+ expect(
+ pushes.sublist(0, pushes.length - 1).every((p) => p[4] == 1),
+ isTrue,
+ );
+ // Chunks respect the transport's frame budget.
+ expect(
+ pushes.every((p) => p.length <= connector.effectiveMaxFrameSize),
+ isTrue,
+ );
+ // Re-read after the commit, and the list shows the radio's answer.
+ expect(sent.last, equals([0xC5, 0x09, 0]));
+ expect(connector.tuneSlots[0]!.length, tune.length);
+ });
+
+ test('a displaced push restarts from 0 once and succeeds', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x10));
+ radio = assembler(displaceOnce: true);
+ final tune = 'Mario:d=4,o=5,b=100:${List.filled(40, '8e6').join(',')}';
+ expect(await connector.saveTuneSlot(0, tune), isTrue);
+ final restarts = sent.where((f) => f[1] == 0x0A && f[3] == 0).length;
+ expect(restarts, 2);
+ expect(connector.deviceUiError, isNull);
+ });
+
+ test('displaced twice: stops and says so', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x10));
+ radio = (f) => f[1] == 0x0A
+ ? [
+ [0xC5, 0x7F, 0x07, 0],
+ ]
+ : const [];
+ final tune = 'Mario:d=4,o=5,b=100:${List.filled(40, '8e6').join(',')}';
+ expect(await connector.saveTuneSlot(0, tune), isFalse);
+ expect(sent.where((f) => f[1] == 0x0A).length, 2);
+ expect(connector.deviceUiErrorSurface, DeviceUiSurface.tuneSlot);
+ expect(connector.deviceUiError, ButtonConfigError.slotOffset.message);
+ });
+
+ test('clearing sends the one empty commit', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x10));
+ radio = assembler();
+ sent.clear();
+ expect(await connector.saveTuneSlot(3, ''), isTrue);
+ expect(sent.first, equals([0xC5, 0x0A, 3, 0, 0]));
+ });
+
+ test('a tune over 240 characters is never sent', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x10));
+ sent.clear();
+ expect(await connector.saveTuneSlot(0, 'x:${'c' * 240}'), isFalse);
+ expect(sent, isEmpty);
+ });
+ });
+
+ group('screen settings', () {
+ List reply(int sub, {int tz = 2}) => [
+ 0xC5, sub, 0, 1, 60, 0, 60, 0, tz, 0x36, 0x10, 0x36, 0x10, 0, //
+ ];
+
+ test('state follows the re-read reply', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x40));
+ await connector.setTimeZone(9);
+ expect(sent.last, equals([0xC5, 0x16, 9]));
+ connector.handleFrameForTest(reply(0x16, tz: 9));
+ expect(connector.screenSettings!.timeZone, 9);
+ });
+
+ test('a battery refusal keeps the radio\'s reason', () async {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x40));
+ connector.handleFrameForTest(reply(0x13));
+ await connector.setBatteryFull(pin: true);
+ connector.handleFrameForTest([0xC5, 0x7F, 0x09]);
+ expect(connector.deviceUiErrorSurface, DeviceUiSurface.screenSettings);
+ expect(
+ connector.deviceUiError,
+ ButtonConfigError.batteryNotCharged.message,
+ );
+ expect(connector.screenSettings!.batteryFullStored, 4150);
+ });
+
+ test('a new radio does not inherit the previous one\'s settings', () {
+ connector.handleFrameForTest(deviceInfo(caps2: 0x50));
+ connector.handleFrameForTest(reply(0x13));
+ connector.handleFrameForTest([0xC5, 0x0F, 0, 1, 1]);
+ connector.handleFrameForTest(deviceInfo(caps2: 0x50));
+ expect(connector.screenSettings, isNull);
+ expect(connector.eventTone(ToneEvent.dm), isNull);
+ });
+ });
+}
diff --git a/test/screens/settings_tones_parity_test.dart b/test/screens/settings_tones_parity_test.dart
new file mode 100644
index 00000000..eb69e717
--- /dev/null
+++ b/test/screens/settings_tones_parity_test.dart
@@ -0,0 +1,397 @@
+// #970, #971, #986, #987: the approved parity screens
+// (docs/initiatives/standalone/975-settings-restructure/
+// tones-screen-parity-mockups.html). Pins the gates, that each list offers
+// exactly what firmware accepts, that play never chooses, and that each
+// control sends the documented request.
+
+import 'package:drift/native.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:meshcore_open/connector/meshcore_connector.dart';
+import 'package:meshcore_open/connector/offband_device_ui.dart';
+import 'package:meshcore_open/connector/offband_screen_settings.dart';
+import 'package:meshcore_open/connector/offband_tones.dart';
+import 'package:meshcore_open/l10n/app_localizations.dart';
+import 'package:meshcore_open/models/channel.dart';
+import 'package:meshcore_open/screens/settings_screen.dart';
+import 'package:meshcore_open/services/app_settings_service.dart';
+import 'package:meshcore_open/storage/drift/blob_store.dart';
+import 'package:meshcore_open/storage/drift/offband_database.dart';
+import 'package:meshcore_open/storage/prefs_manager.dart';
+import 'package:meshcore_open/widgets/radio_tone.dart';
+import 'package:provider/provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'dart:typed_data';
+
+class _Radio extends MeshCoreConnector {
+ _Radio({this.tunes = true, this.screen = true, this.playResult});
+ final bool tunes;
+ final bool screen;
+ final TunePlayResult? playResult;
+ final List calls = [];
+
+ ScreenSettingsState settings = const ScreenSettingsState(
+ navText: BadgeTextSize.large,
+ messageText: BadgeTextSize.medium,
+ screenOffStored: 60,
+ screenOffSeconds: 60,
+ timeZone: 2,
+ batteryFullStored: 0,
+ batteryFullMv: 4200,
+ batteryPinned: false,
+ );
+
+ @override
+ bool get supportsNotifyScope => true;
+ @override
+ bool get supportsButtonMatrix => false;
+ @override
+ bool get supportsTunes => tunes;
+ @override
+ bool get supportsScreenSettings => screen;
+ @override
+ DeviceNotifyScope? get deviceNotifyScope => DeviceNotifyScope.all;
+
+ @override
+ EventToneState? eventTone(ToneEvent event) => switch (event) {
+ ToneEvent.dm => const EventToneState(stored: 0xFF, effective: 1),
+ ToneEvent.channel => const EventToneState(stored: 0xFF, effective: 2),
+ ToneEvent.room => const EventToneState(stored: 8, effective: 8),
+ _ => const EventToneState(stored: 0, effective: 0),
+ };
+
+ @override
+ Map get tuneSlots => {
+ 0: const TuneSlotInfo(length: 34, name: 'Mario'),
+ for (var s = 1; s < tuneSlotCount; s++)
+ s: const TuneSlotInfo(length: 0, name: ''),
+ };
+
+ @override
+ ScreenSettingsState? get screenSettings => screen ? settings : null;
+
+ @override
+ ChannelTune? channelTuneFor(int channel) => const ChannelTune.inherit();
+
+ @override
+ Future requestNotifyScope() async => calls.add('scope');
+ @override
+ Future requestEventTones() async => calls.add('evtones');
+ @override
+ Future requestTuneSlots() async => calls.add('slots');
+ @override
+ Future requestScreenSettings() async => calls.add('screen');
+ @override
+ Future requestChannelTune(int channel) async =>
+ calls.add('chtune $channel');
+ @override
+ Future setEventTone(ToneEvent event, int tuneId) async =>
+ calls.add('evtone ${event.name} $tuneId');
+ @override
+ Future setChannelTune(int channel, ChannelTune tune) async =>
+ calls.add('chtune set $channel ${tune.stored}');
+ @override
+ Future playTune(int tuneId) async {
+ calls.add('play $tuneId');
+ return playResult ?? TunePlayResult.played;
+ }
+
+ @override
+ Future readTuneSlot(
+ int slot, {
+ void Function(int bytesRead)? onProgress,
+ }) async {
+ calls.add('read $slot');
+ return 'Mario:d=4,o=5,b=100:16e6,16e6,32p';
+ }
+
+ @override
+ Future setTextSize(BadgeTextTarget target, BadgeTextSize size) async =>
+ calls.add('text ${target.name} ${size.name}');
+ @override
+ Future setScreenOff(int seconds) async => calls.add('scroff $seconds');
+ @override
+ Future setTimeZone(int index) async => calls.add('tz $index');
+ @override
+ Future setBatteryFull({required bool pin}) async =>
+ calls.add('batt ${pin ? 'pin' : 'auto'}');
+}
+
+void main() {
+ late OffbandDatabase db;
+
+ setUp(() async {
+ SharedPreferences.setMockInitialValues({});
+ PrefsManager.reset();
+ await PrefsManager.initialize();
+ db = OffbandDatabase(NativeDatabase.memory());
+ BlobStore.overrideForTest(BlobStore(db));
+ });
+
+ tearDown(() async {
+ BlobStore.clearTestOverride();
+ await db.close();
+ });
+
+ Future pump(WidgetTester tester, _Radio radio, Widget home) async {
+ radio.sendFrameOverrideForTest = (_) {};
+ tester.view.physicalSize = const Size(400, 1000);
+ tester.view.devicePixelRatio = 1.0;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+ await tester.pumpWidget(
+ MultiProvider(
+ providers: [
+ ChangeNotifierProvider.value(value: radio),
+ ChangeNotifierProvider(
+ create: (_) => AppSettingsService(),
+ ),
+ ],
+ child: MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: home,
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+ }
+
+ Future tapText(WidgetTester tester, String text) async {
+ await tester.scrollUntilVisible(
+ find.text(text),
+ 200,
+ scrollable: find.byType(Scrollable).last,
+ );
+ // scrollUntilVisible can stop with the row at the very edge, where a tap
+ // lands on whatever is drawn over it.
+ await tester.ensureVisible(find.text(text));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text(text));
+ await tester.pumpAndSettle();
+ }
+
+ Future openBuzzer(WidgetTester tester, _Radio radio) async {
+ await pump(tester, radio, const SettingsScreen());
+ await tapText(tester, 'Message Settings');
+ await tapText(tester, 'Buzzer and tones');
+ }
+
+ group('Buzzer and tones', () {
+ testWidgets('scope, the six tones and all eight slots', (tester) async {
+ final radio = _Radio();
+ await openBuzzer(tester, radio);
+ expect(radio.calls, containsAll(['scope', 'evtones', 'slots']));
+ for (final e in ToneEvent.values) {
+ await tester.scrollUntilVisible(
+ find.text(e.label),
+ 100,
+ scrollable: find.byType(Scrollable).last,
+ );
+ expect(find.text(e.label), findsOneWidget);
+ }
+ await tester.scrollUntilVisible(
+ find.text('8 (empty)'),
+ 100,
+ scrollable: find.byType(Scrollable).last,
+ );
+ expect(find.text('8 (empty)'), findsOneWidget);
+ });
+
+ testWidgets('a default and a choice never read alike', (tester) async {
+ await openBuzzer(tester, _Radio());
+ expect(find.text('Chime (radio default)'), findsOneWidget);
+ expect(find.text('Mario'), findsOneWidget);
+ });
+
+ testWidgets('no 0x10: the scope alone, as before', (tester) async {
+ final radio = _Radio(tunes: false);
+ await openBuzzer(tester, radio);
+ expect(find.text('Device notification scope'), findsOneWidget);
+ expect(find.text('Tones'), findsNothing);
+ expect(find.text('Custom tunes'), findsNothing);
+ expect(radio.calls, isNot(contains('evtones')));
+ });
+
+ testWidgets('the picker lists what firmware accepts; play never chooses', (
+ tester,
+ ) async {
+ final radio = _Radio();
+ await openBuzzer(tester, radio);
+ await tapText(tester, 'DM');
+
+ final dialog = find.byType(AlertDialog);
+ for (final name in [
+ 'Silent',
+ 'Chime',
+ 'Kerplop',
+ 'Tick',
+ 'Bloop',
+ 'Drop',
+ 'Knock',
+ 'Mario',
+ ]) {
+ expect(
+ find.descendant(of: dialog, matching: find.text(name)),
+ findsOneWidget,
+ );
+ }
+ // No Default row for an event (owner decision 3), no empty slot.
+ expect(
+ find.descendant(of: dialog, matching: find.text('Default')),
+ findsNothing,
+ );
+ expect(
+ find.descendant(of: dialog, matching: find.textContaining('empty')),
+ findsNothing,
+ );
+
+ final kerplop = find.ancestor(
+ of: find.descendant(of: dialog, matching: find.text('Kerplop')),
+ matching: find.byType(ListTile),
+ );
+ await tester.tap(
+ find.descendant(
+ of: kerplop,
+ matching: find.byIcon(Icons.play_circle_outline),
+ ),
+ );
+ await tester.pumpAndSettle();
+ expect(radio.calls, contains('play 2'));
+ expect(radio.calls.where((c) => c.startsWith('evtone ')), isEmpty);
+
+ await tester.tap(
+ find.descendant(of: dialog, matching: find.text('Bloop')),
+ );
+ await tester.pumpAndSettle();
+ expect(radio.calls, contains('evtone dm 4'));
+ });
+
+ testWidgets('a silent preview says why', (tester) async {
+ final radio = _Radio(playResult: TunePlayResult.soundOff);
+ await openBuzzer(tester, radio);
+ await tapText(tester, 'DM');
+ final dialog = find.byType(AlertDialog);
+ final chime = find.ancestor(
+ of: find.descendant(of: dialog, matching: find.text('Chime')),
+ matching: find.byType(ListTile),
+ );
+ await tester.tap(
+ find.descendant(
+ of: chime,
+ matching: find.byIcon(Icons.play_circle_outline),
+ ),
+ );
+ await tester.pumpAndSettle();
+ expect(find.textContaining('Sound is off on this radio'), findsOneWidget);
+ });
+ });
+
+ group('slot editor', () {
+ testWidgets('opens with the stored tune and checks typing', (tester) async {
+ final radio = _Radio();
+ await openBuzzer(tester, radio);
+ await tapText(tester, '1 Mario');
+ expect(radio.calls, contains('read 0'));
+ expect(find.text('Mario:d=4,o=5,b=100:16e6,16e6,32p'), findsOneWidget);
+ expect(find.text('Plays as "Mario"'), findsOneWidget);
+
+ await tester.enterText(
+ find.byType(TextField),
+ 'Doorbell:d=4,o=5,b=0:8e6',
+ );
+ await tester.pump();
+ expect(find.text('b= must be 1 or more'), findsOneWidget);
+ final save = tester.widget(
+ find.widgetWithText(FilledButton, 'Save'),
+ );
+ expect(save.onPressed, isNull);
+ });
+ });
+
+ group('Node Settings', () {
+ testWidgets('Screen and Battery only with 0x40', (tester) async {
+ await pump(tester, _Radio(screen: false), const SettingsScreen());
+ await tapText(tester, 'Node Settings');
+ expect(find.text('Screen'), findsNothing);
+ expect(find.text('Battery'), findsNothing);
+ });
+
+ testWidgets('the Screen page sends each control\'s request', (
+ tester,
+ ) async {
+ final radio = _Radio();
+ await pump(tester, radio, const SettingsScreen());
+ await tapText(tester, 'Node Settings');
+ expect(find.text('100% at 4200 mV'), findsOneWidget);
+ await tapText(tester, 'Screen');
+
+ await tester.tap(find.text('Small').last);
+ await tester.pumpAndSettle();
+ expect(radio.calls, contains('text messages small'));
+
+ await tester.tap(find.text('5 m'));
+ await tester.pumpAndSettle();
+ expect(radio.calls, contains('scroff 300'));
+
+ await tapText(tester, 'Eastern');
+ await tapText(tester, 'Central');
+ expect(radio.calls, contains('tz 3'));
+ });
+
+ testWidgets('Battery: pin asks first; Back to auto only when set', (
+ tester,
+ ) async {
+ final radio = _Radio();
+ await pump(tester, radio, const SettingsScreen());
+ await tapText(tester, 'Node Settings');
+ await tapText(tester, 'Battery');
+ expect(find.text('Default for this board'), findsOneWidget);
+ expect(find.text('Back to auto'), findsNothing);
+
+ await tester.tap(find.text('Set 100% here'));
+ await tester.pumpAndSettle();
+ expect(radio.calls, isNot(contains('batt pin')));
+ await tester.tap(find.text('Set 100% here').last);
+ await tester.pumpAndSettle();
+ expect(radio.calls, contains('batt pin'));
+ });
+ });
+
+ group('channel Radio tone', () {
+ final channel = Channel(index: 3, name: '#okimesh', psk: Uint8List(16));
+
+ Widget tile() => Scaffold(
+ body: Builder(
+ builder: (context) => ChannelRadioToneTile(
+ channel: channel,
+ onTap: () => showChannelRadioToneDialog(context, channel),
+ ),
+ ),
+ );
+
+ testWidgets('absent without 0x10', (tester) async {
+ await pump(tester, _Radio(tunes: false), tile());
+ expect(find.text('Radio tone'), findsNothing);
+ });
+
+ testWidgets('Default names what it plays, and is selectable', (
+ tester,
+ ) async {
+ final radio = _Radio();
+ await pump(tester, radio, tile());
+ expect(radio.calls, contains('chtune 3'));
+ expect(find.text('Default'), findsOneWidget);
+
+ await tester.tap(find.text('Radio tone'));
+ await tester.pumpAndSettle();
+ expect(find.text('Kerplop, the radio\'s channel tone'), findsOneWidget);
+
+ await tester.tap(find.text('Silent'));
+ await tester.pumpAndSettle();
+ expect(radio.calls, contains('chtune set 3 1'));
+ // Let the refusal watcher's window close.
+ await tester.pump(const Duration(seconds: 6));
+ });
+ });
+}
diff --git a/untranslated.json b/untranslated.json
index 6a12ec1f..fc982b3c 100644
--- a/untranslated.json
+++ b/untranslated.json
@@ -61,6 +61,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -375,6 +380,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -689,6 +699,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -1003,6 +1018,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -1317,6 +1337,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -1631,6 +1656,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -1945,6 +1975,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -2259,6 +2294,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -2573,6 +2613,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -2887,6 +2932,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -3201,6 +3251,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -3515,6 +3570,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -3829,6 +3889,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -4143,6 +4208,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -4457,6 +4527,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -4771,6 +4846,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",
@@ -5085,6 +5165,11 @@
"settings_gpsSleepInvalid",
"settings_buttonActions",
"settings_buttonActionsSubtitle",
+ "settings_badgeScreen",
+ "settings_badgeScreenSubtitle",
+ "settings_badgeBattery",
+ "settings_badgeBatterySubtitle",
+ "settings_badgeBatteryReading",
"settings_buzzerTones",
"settings_buzzerTonesSubtitle",
"settings_experimental",