Merge remote-tracking branch 'origin/dev' into fix/1111-osm-identity

fix/1111-osm-identity
diffwireauto 6 days ago
commit fdbb0a3942

@ -69,6 +69,7 @@ import '../storage/channel_region_scope_store.dart';
import 'offband_capability_advert.dart';
import 'offband_channel_notify.dart';
import 'offband_device_ui.dart';
import 'offband_indicators.dart';
import 'offband_screen_settings.dart';
import 'offband_tones.dart';
import 'caplog_reassembler.dart';
@ -463,6 +464,9 @@ class MeshCoreConnector extends ChangeNotifier {
final Map<ToneEvent, EventToneState> _eventTones = {};
final Map<int, TuneSlotInfo> _tuneSlots = {};
ScreenSettingsState? _screenSettings;
bool? _ledEnabled;
DisplayMode? _displayMode;
int? _displayRotation;
bool _tuneSlotTransferActive = false;
final List<_DeviceUiWaiter> _deviceUiWaiters = [];
int _pathHashByteWidth = 1;
@ -1114,6 +1118,23 @@ class MeshCoreConnector extends ChangeNotifier {
bool get supportsScreenSettings =>
firmwareSupportsScreenSettings(_offbandCaps2);
/// Whether this radio has a settable display or LED (#523, caps byte 2
/// `0x04`). Which of the two it has, it says only by refusing the other.
bool get supportsIndicators => firmwareSupportsIndicators(_offbandCaps2);
/// The LED and display state the radio last reported, from device-info or a
/// reply. Null when it has not said.
bool? get ledEnabled => _ledEnabled;
DisplayMode? get displayMode => _displayMode;
/// Degrees, or null until the radio answers the rotation GET (`0x1A`).
///
/// Null is "not read yet", NOT "cannot rotate". Capability is
/// [supportsDisplayRotation], which reads the advert tag; conflating the two
/// is what the old `supportsIndicators && _displayRotation != null` did, and
/// it made an unanswered read indistinguishable from an unsupported board.
int? get displayRotation => _displayRotation;
/// The tune the radio reported for [channel], or null if not read yet.
ChannelTune? channelTuneFor(int channel) => _channelTunes[channel];
@ -1420,6 +1441,9 @@ class MeshCoreConnector extends ChangeNotifier {
/// calling it before the list is synced is a no-op rather than a guess.
void _reconcileAdvertisedDeviceUi() {
unawaited(_readChannelNotifyModes());
// Rotation's gate is TAG_DISPLAY_ROTATION, so this is the first point at
// which it can be asked for at all (#1098).
if (supportsDisplayRotation) unawaited(requestDisplayRotation());
}
/// Ask the radio which notify mode [channel] has for its own buzzer (#544).
@ -1817,6 +1841,66 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> setBatteryFull({required bool pin}) =>
_sendScreenSet('batt SET', buildBatteryFullSetFrame(pin: pin));
Future<void> _sendIndicatorSet(
String what,
DeviceUiSurface surface,
Uint8List frame,
) async {
if (!_deviceUiGateOpen(what, advertised: supportsIndicators)) return;
_deviceUiError = null;
_deviceUiErrorSurface = null;
_pendingDeviceUiWrite = surface;
_appDebugLogService?.info('$what -> $frame', tag: 'DeviceUI');
await sendFrame(frame);
}
Future<void> setDisplayMode(DisplayMode mode) => _sendIndicatorSet(
'display SET',
DeviceUiSurface.display,
buildDisplaySetFrame(mode),
);
/// Refused locally until the radio has answered the rotation GET, so
/// firmware that cannot rotate is never sent a SET.
Future<void> setDisplayRotation(int degrees) async {
if (!supportsDisplayRotation) {
_setDeviceUiError(
DeviceUiSurface.rotation,
'This radio has not reported a rotation it can change',
);
return;
}
await _sendIndicatorSet(
'rotation SET',
DeviceUiSurface.rotation,
buildRotationSetFrame(degrees),
);
}
/// Read this radio's current rotation.
///
/// This is a plain GET, not a capability probe. Rotation support comes from
/// `TAG_DISPLAY_ROTATION` in the `0x1C` advert, so the old probe (send
/// `0x18`, treat a refusal as "cannot rotate") is gone: contract §3 forbids
/// "discovering support by trying each feature's sub-code and watching for
/// errors", and the probe also had to borrow `_pendingDeviceUiWrite` for a
/// read, which meant a genuine write refusal could be eaten by it.
Future<void> requestDisplayRotation() async {
if (!_deviceUiGateOpen(
'rotation GET',
advertised: supportsDisplayRotation,
)) {
return;
}
await sendFrame(buildRotationGetFrame());
}
Future<void> setLedEnabled(bool on) => _sendIndicatorSet(
'led SET',
DeviceUiSurface.led,
buildLedSetFrame(on: on),
);
/// 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)
@ -1855,6 +1939,8 @@ class MeshCoreConnector extends ChangeNotifier {
if (supportsGpsMode) requestGpsMode();
// Node Settings names the battery full point in its Battery row.
if (supportsScreenSettings) requestScreenSettings();
// Rotation is NOT driven from here: its gate is an advert tag, which is
// not known yet at this point. It is driven from the advert reply instead.
}
/// Single entry point for `0xC5`: both surfaces and the shared error sub-code
@ -1907,6 +1993,11 @@ class MeshCoreConnector extends ChangeNotifier {
_handleScreenSettingsReply(screen);
return;
}
final indicator = parseIndicatorReply(frame);
if (indicator != null) {
_handleIndicatorReply(indicator);
return;
}
_appDebugLogService?.warn(
'0xC5 reply not understood (sub=0x'
'${frame.length > 1 ? frame[1].toRadixString(16).padLeft(2, '0') : '??'}, '
@ -1948,6 +2039,9 @@ class MeshCoreConnector extends ChangeNotifier {
DeviceUiSurface.tuneSlot => 'Tune slot',
DeviceUiSurface.screenSettings => 'Screen settings',
DeviceUiSurface.channelNotify => 'Radio channel notifications',
DeviceUiSurface.display => 'Display',
DeviceUiSurface.led => 'LED',
DeviceUiSurface.rotation => 'Rotation',
null => 'Device UI request',
};
_appDebugLogService?.warn(
@ -2040,6 +2134,26 @@ class MeshCoreConnector extends ChangeNotifier {
notifyListeners();
}
void _handleIndicatorReply(IndicatorReply reply) {
final surface = reply.surface;
if (reply.isWrite && _pendingDeviceUiWrite == surface) {
_pendingDeviceUiWrite = null;
}
if (reply.errorMessage != null) {
_setDeviceUiError(surface, reply.errorMessage!);
return;
}
if (reply.displayMode != null) _displayMode = reply.displayMode;
if (reply.rotation != null) _displayRotation = reply.rotation;
if (reply.ledOn != null) _ledEnabled = reply.ledOn;
_appDebugLogService?.info(
'indicator reply: display=${_displayMode?.name} '
'rotation=$_displayRotation led=$_ledEnabled',
tag: 'DeviceUI',
);
notifyListeners();
}
void _handleChannelTuneReply(OffbandUiReply reply) {
final t = reply.channelTune;
if (t == null) {
@ -4919,6 +5033,9 @@ class MeshCoreConnector extends ChangeNotifier {
_advertRequestOutstanding = false;
_advertContinuationRound = 0;
_advertTags.clear();
_ledEnabled = null;
_displayMode = null;
_displayRotation = null;
_blockListRetryTimer?.cancel();
// Per-connection debounce timers must die with the connection so a timer
// scheduled against this radio can't fire against the next one (#838 review).
@ -8645,6 +8762,21 @@ class MeshCoreConnector extends ChangeNotifier {
? frame[4] | (frame[5] << 8) | (frame[6] << 16) | (frame[7] << 24)
: null;
/// LED (byte 85) and display mode (byte 86), appended unconditionally on
/// v21+ (firmware #542). Each is null when the frame stops short of it. Like
/// the FEM byte, presence says nothing about capability:
/// [supportsIndicators] does.
///
/// Bytes 82 to 86 keep working and are explicitly untouched by the advert
/// contract (§7). Nothing here reads 87, and nothing may: rotation is read
/// over `0x1A`/`0x1B` and advertised by `TAG_DISPLAY_ROTATION`.
static ({bool? ledOn, DisplayMode? mode}) parseIndicatorBytes(
Uint8List frame,
) => (
ledOn: frame.length >= 86 ? frame[85] != 0 : null,
mode: frame.length >= 87 ? DisplayMode.fromCode(frame[86]) : null,
);
void _handleDeviceInfo(Uint8List frame) {
if (frame.length < 4) return;
if (_shouldGateInitialChannelSync) {
@ -8714,12 +8846,19 @@ class MeshCoreConnector extends ChangeNotifier {
_femLnaEnabled = parseFemLnaState(frame);
// Second caps byte rides at offset 84, past the FEM state byte (#480).
_offbandCaps2 = parseOffbandCaps2(frame);
// Nothing is read past byte 84 here. Offset 87 is where three features
// collided; capabilities beyond caps2 live in the 0x1C advert (#1096).
// LED (85) and display mode (86) ride past caps2 (#523). Bytes 82 to 86
// keep working; offset 87 is where three features collided and is read
// by nothing. Capabilities beyond caps2 live in the 0x1C advert (#1096).
final indicators = parseIndicatorBytes(frame);
_ledEnabled = indicators.ledOn;
_displayMode = indicators.mode;
} else {
_offbandCaps = null;
_femLnaEnabled = null;
_offbandCaps2 = null;
_ledEnabled = null;
_displayMode = null;
_displayRotation = null;
}
// Capability-gated features are invisible when a bit is clear, which looks
// identical to a bug. Log the raw inputs so "the toggle didn't appear" can

@ -101,7 +101,8 @@ enum ButtonConfigError {
0x09,
'Not charged yet. Charge the radio fully, then set 100% again. Nothing '
'was changed.',
);
),
noRotation(0x0A, "This radio's display cannot be rotated");
const ButtonConfigError(this.code, this.message);
final int code;
@ -134,6 +135,11 @@ enum DeviceUiSurface {
/// The radio's own per-channel notify mode (#544). Distinct from the app's
/// per-channel notify setting, which is not a device-UI surface at all.
channelNotify,
display,
led,
/// Display rotation over `0xC5` `0x1A`/`0x1B` (#1098, firmware #1990).
rotation,
}
/// The refusal reason for a GPS write. Reason 1 means Power save on a build

@ -0,0 +1,160 @@
import 'dart:typed_data';
import 'offband_device_ui.dart';
/// The radio's display and LED over `0xC5` (#523, #944): display mode, display
/// rotation and the status LED. Gated on caps byte 2 `0x04` INDICATORS
/// (meshcore-firmware #542, rotation #1990).
///
/// display GET `[0xC5][0x05]` -> `[0xC5][0x05][mode]`
/// SET `[0xC5][0x06][mode]` -> the same
/// led GET `[0xC5][0x07]` -> `[0xC5][0x07][on]`
/// SET `[0xC5][0x08][on]` -> the same
/// rotation GET `[0xC5][0x1A]` -> `[0xC5][0x1A][deg]`
/// SET `[0xC5][0x1B][deg]` -> the same
///
/// ROTATION IS `0x1A`/`0x1B`, NOT `0x18`/`0x19`. Those are CHNOTIFY, and this
/// file held them until 2026-11-04. The collision was not theoretical:
/// firmware `4e5a6c85`, already flashed on the owner's T1000-E, answers
/// `0x18`/`0x19` as per-channel channel-notify and its SET validates only
/// `mode < 4`, so a rotation SET of `0` landed as a valid notify-mode write on
/// channel slot 0, which is Public and undeletable, and BOTH sides reported
/// success. No error, no banner, nothing in any log.
///
/// The numbers here are transcribed from firmware
/// `examples/companion_radio/OffbandConfigProtocol.h` on `firmware-base` at
/// `28785ebf` (PR OffbandMesh/meshcore-firmware#2000). The header is the
/// registry. Do not take a sub-code from an Agent Mail message or from this
/// comment: read the header, because taking numbers from mail is what caused
/// the collision.
///
/// Rotation is advertised by `TAG_DISPLAY_ROTATION` in the `0xC5` `0x1C`
/// advert, NOT by caps2 `0x04` INDICATORS and NOT by probing `0x1A` and
/// watching for a refusal. Contract §3 forbids that probing outright:
/// "what is forbidden is discovering support by trying each feature's sub-code
/// and watching for errors." Display mode and the LED keep the INDICATORS bit.
///
/// A board without a display, or without a controllable LED, refuses that
/// half with reason 6; a display that cannot turn refuses rotation with 10.
const int offbandCap2Indicators = 0x04;
const int offbandUiDisplayGet = 0x05;
const int offbandUiDisplaySet = 0x06;
const int offbandUiLedGet = 0x07;
const int offbandUiLedSet = 0x08;
const int offbandUiRotationGet = 0x1A;
const int offbandUiRotationSet = 0x1B;
/// The rotations the radio accepts, in degrees.
const List<int> displayRotations = [0, 180];
bool firmwareSupportsIndicators(int? offbandCaps2) =>
offbandCaps2 != null && (offbandCaps2 & offbandCap2Indicators) != 0;
/// When the radio's screen is lit. Values are the wire encoding.
enum DisplayMode {
auto(0, 'Auto', 'On while in use, then off after the screen timeout'),
alwaysOn(1, 'Always on', 'Never turns off by itself'),
alwaysOff(2, 'Always off', 'Stays dark');
const DisplayMode(this.code, this.label, this.description);
final int code;
final String label;
final String description;
static DisplayMode? fromCode(int code) {
for (final m in DisplayMode.values) {
if (m.code == code) return m;
}
return null;
}
}
Uint8List buildDisplayGetFrame() =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiDisplayGet]);
Uint8List buildDisplaySetFrame(DisplayMode mode) =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiDisplaySet, mode.code]);
Uint8List buildLedGetFrame() =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiLedGet]);
Uint8List buildLedSetFrame({required bool on}) =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiLedSet, on ? 1 : 0]);
Uint8List buildRotationGetFrame() =>
Uint8List.fromList([cmdOffbandDeviceUi, offbandUiRotationGet]);
Uint8List buildRotationSetFrame(int degrees) {
if (!displayRotations.contains(degrees)) {
throw ArgumentError.value(degrees, 'degrees', 'must be 0 or 180');
}
return Uint8List.fromList([
cmdOffbandDeviceUi,
offbandUiRotationSet,
degrees,
]);
}
/// Parse a display, LED or rotation reply. Null if this is not one, or if it
/// is truncated. The shared 0x7F error is owned by [parseButtonMatrixReply].
IndicatorReply? parseIndicatorReply(Uint8List frame) {
if (frame.length < 3 || frame[0] != respCodeOffbandDeviceUi) return null;
final sub = frame[1];
final value = frame[2];
switch (sub) {
case offbandUiLedGet || offbandUiLedSet:
return IndicatorReply(sub: sub, ledOn: value != 0);
case offbandUiDisplayGet || offbandUiDisplaySet:
final mode = DisplayMode.fromCode(value);
if (mode == null) {
return IndicatorReply(
sub: sub,
errorMessage:
'The radio reported an unknown display mode (${_hex(value)})',
);
}
return IndicatorReply(sub: sub, displayMode: mode);
case offbandUiRotationGet || offbandUiRotationSet:
if (!displayRotations.contains(value)) {
return IndicatorReply(
sub: sub,
errorMessage: 'The radio reported an unknown rotation ($value)',
);
}
return IndicatorReply(sub: sub, rotation: value);
}
return null;
}
String _hex(int b) => '0x${b.toRadixString(16).padLeft(2, '0')}';
class IndicatorReply {
const IndicatorReply({
required this.sub,
this.displayMode,
this.ledOn,
this.rotation,
this.errorMessage,
});
final int sub;
final DisplayMode? displayMode;
final bool? ledOn;
/// Degrees, always one of [displayRotations].
final int? rotation;
final String? errorMessage;
DeviceUiSurface get surface => switch (sub) {
offbandUiLedGet || offbandUiLedSet => DeviceUiSurface.led,
offbandUiRotationGet || offbandUiRotationSet => DeviceUiSurface.rotation,
_ => DeviceUiSurface.display,
};
bool get isWrite =>
sub == offbandUiDisplaySet ||
sub == offbandUiLedSet ||
sub == offbandUiRotationSet;
}

@ -385,6 +385,8 @@
"settings_buttonActionsSubtitle": "Assign what the button does",
"settings_badgeScreen": "Screen",
"settings_badgeScreenSubtitle": "Text size, screen off, time zone",
"settings_indicators": "Display and LED",
"settings_indicatorsSubtitle": "Display mode, rotation, status LED",
"settings_badgeBattery": "Battery",
"settings_badgeBatterySubtitle": "100% at {mv} mV",
"@settings_badgeBatterySubtitle": {

@ -1240,6 +1240,18 @@ abstract class AppLocalizations {
/// **'Text size, screen off, time zone'**
String get settings_badgeScreenSubtitle;
/// No description provided for @settings_indicators.
///
/// In en, this message translates to:
/// **'Display and LED'**
String get settings_indicators;
/// No description provided for @settings_indicatorsSubtitle.
///
/// In en, this message translates to:
/// **'Display mode, rotation, status LED'**
String get settings_indicatorsSubtitle;
/// No description provided for @settings_badgeBattery.
///
/// In en, this message translates to:

@ -668,6 +668,13 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -670,6 +670,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -664,6 +664,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -668,6 +668,13 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -671,6 +671,13 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -670,6 +670,13 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -670,6 +670,13 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -653,6 +653,13 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -653,6 +653,13 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -667,6 +667,13 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -673,6 +673,13 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -670,6 +670,13 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -669,6 +669,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -668,6 +668,13 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -666,6 +666,13 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -665,6 +665,13 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -667,6 +667,13 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -649,6 +649,13 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settings_badgeScreenSubtitle => 'Text size, screen off, time zone';
@override
String get settings_indicators => 'Display and LED';
@override
String get settings_indicatorsSubtitle =>
'Display mode, rotation, status LED';
@override
String get settings_badgeBattery => 'Battery';

@ -65,13 +65,15 @@ class _DeviceUiViewState extends State<DeviceUiView> {
DeviceUiSurface.eventTone ||
DeviceUiSurface.tunePlay ||
DeviceUiSurface.tuneSlot => widget.part == DeviceUiPart.buzzer,
DeviceUiSurface.gpsMode || DeviceUiSurface.screenSettings => false,
// The radio's per-channel notify control lives beside the app's own
// per-channel control on the Channels screen, not in this pane (#544
// Epic C), so a refusal for it is not this screen's to show. Epic C1
// surfaces it there; until then nothing on this pane can emit it, because
// nothing here writes it.
DeviceUiSurface.channelNotify => false,
// Epic C), so a refusal for it is not this screen's to show.
DeviceUiSurface.gpsMode ||
DeviceUiSurface.screenSettings ||
DeviceUiSurface.channelNotify ||
DeviceUiSurface.display ||
DeviceUiSurface.led ||
DeviceUiSurface.rotation => false,
};
@override

@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../connector/meshcore_connector.dart';
import '../../connector/offband_device_ui.dart';
import '../../connector/offband_indicators.dart';
import '../../widgets/device_ui_error_banner.dart';
/// The radio's display and LED (#523, #944): display mode, rotation and the
/// status LED. Gated on caps byte 2 `0x04`; the Node Settings row that leads
/// here is gated the same way.
///
/// Every control draws from what the radio reported, never from the tap. A
/// board without a display or without a controllable LED refuses that write
/// and says so in the banner. English-only, matching [DeviceUiView].
class IndicatorsView extends StatelessWidget {
const IndicatorsView({super.key});
@override
Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
final error = connector.deviceUiError;
final errorSurface = connector.deviceUiErrorSurface;
final mode = connector.displayMode;
final led = connector.ledEnabled;
return ListView(
children: [
if (error != null &&
(errorSurface == DeviceUiSurface.display ||
errorSurface == DeviceUiSurface.led ||
errorSurface == DeviceUiSurface.rotation))
DeviceUiErrorBanner(
message: error,
onDismiss: connector.clearDeviceUiError,
),
const _Header('Display'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SegmentedButton<DisplayMode>(
segments: [
for (final m in DisplayMode.values)
ButtonSegment(value: m, label: Text(m.label)),
],
selected: {?mode},
emptySelectionAllowed: true,
onSelectionChanged: (v) {
if (v.isNotEmpty) connector.setDisplayMode(v.first);
},
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
mode?.description ?? 'The radio has not reported its display mode',
style: Theme.of(context).textTheme.bodySmall,
),
),
if (connector.supportsDisplayRotation) ...[
const _Header('Rotation'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SegmentedButton<int>(
segments: const [
ButtonSegment(value: 0, label: Text('Normal')),
ButtonSegment(value: 180, label: Text('Flipped')),
],
// Null until the radio answers the rotation GET, and the
// capability tag arrives BEFORE that reply, so on any slow link
// this builds with no value at all. Empty selection, never a
// force unwrap, matching the display-mode control above.
selected: {?connector.displayRotation},
emptySelectionAllowed: true,
onSelectionChanged: (v) {
if (v.isNotEmpty) connector.setDisplayRotation(v.first);
},
),
),
],
const _Header('LED'),
SwitchListTile(
title: const Text('Status LED'),
subtitle: Text(
led == null
? 'The radio has not reported its LED'
: 'Turn off to save battery or to stay unseen',
),
value: led ?? false,
onChanged: connector.setLedEnabled,
),
],
);
}
}
class _Header extends StatelessWidget {
const _Header(this.title);
final String title;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
);
}

@ -28,6 +28,7 @@ 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/indicators_view.dart';
import 'settings/device_ui_view.dart';
import 'settings/location_view.dart';
import '../helpers/device_name_validator.dart';
@ -496,6 +497,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
],
// Display mode, rotation and LED (#523). Strictly on the 0x04
// bit: no bit, no row, no command.
if (connector.supportsIndicators) ...[
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.brightness_6_outlined),
title: Text(l10n.settings_indicators),
subtitle: Text(l10n.settings_indicatorsSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => _pushBadgePage(
context,
l10n.settings_indicators,
const IndicatorsView(),
),
),
],
if (connector.selfPublicKey != null) ...[
const Divider(height: 1),
Padding(

@ -0,0 +1,353 @@
// #523 / #944: display mode, rotation and the status LED over 0xC5. Display and
// LED are 0x05 to 0x08 (#542) under the INDICATORS caps bit.
//
// ROTATION IS 0x1A / 0x1B, advertised by TAG_DISPLAY_ROTATION in the 0xC5 0x1C
// capability advert (#1098, firmware #1990). It was 0x18 / 0x19 under the
// INDICATORS bit until 2026-11-04, and both of those were wrong:
//
// 0x18 / 0x19 are CHNOTIFY. Firmware 4e5a6c85, already flashed on the
// owner's T1000-E, answers them as per-channel notify and its SET validates
// only `mode < 4`, so a rotation SET of 0 landed as a valid notify-mode
// write on channel slot 0, which is Public, with success reported on BOTH
// sides. Silent corruption, no error anywhere.
//
// Discovering rotation by sending the GET and watching for a refusal is
// forbidden by contract section 3: "what is forbidden is discovering support
// by trying each feature's sub-code and watching for errors." The tag
// replaces the probe, so capability and value are no longer conflated.
//
// Pins the gate (the tag, not 0x04 and not a probe), the device-info bytes 85
// and 86, that offset 87 is read by nothing, and that every value follows the
// radio's reply rather than the tap.
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_capability_advert.dart';
import 'package:meshcore_open/connector/offband_device_ui.dart';
import 'package:meshcore_open/connector/offband_indicators.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';
/// An Offband device-info reply: caps byte 2 at 84, then LED and display mode.
/// [extra] appends bytes past 86, to prove nothing there is read as rotation.
List<int> deviceInfo({
int caps2 = offbandCap2Indicators,
int led = 1,
int mode = 0,
List<int> extra = const [],
}) {
final v = ascii.encode('1.5.0-1.17.0');
final f = Uint8List(87 + extra.length);
f[0] = 13;
f[1] = 22;
f.setRange(60, 60 + v.length, v);
f[84] = caps2;
f[85] = led;
f[86] = mode;
f.setRange(87, 87 + extra.length, extra);
return f;
}
void main() {
late OffbandDatabase db;
late MeshCoreConnector connector;
late List<List<int>> sent;
setUp(() async {
SharedPreferences.setMockInitialValues({});
PrefsManager.reset();
await PrefsManager.initialize();
db = OffbandDatabase(NativeDatabase.memory());
BlobStore.overrideForTest(BlobStore(db));
sent = [];
connector = MeshCoreConnector();
connector.sendFrameOverrideForTest = (data) => sent.add(data.toList());
connector.setConnectedForTest();
});
tearDown(() async {
BlobStore.clearTestOverride();
await db.close();
});
bool sentSub(int sub) => sent.any(
(f) => f.length >= 2 && f[0] == cmdOffbandDeviceUi && f[1] == sub,
);
/// A `0xC5` `0x1C` advert reply carrying [tags] as zero-length records.
List<int> advert(List<int> tags) => [
cmdOffbandDeviceUi,
offbandUiInfoGet,
for (final t in tags) ...[t, 0x00],
];
/// `[0xC5][0x7F][0x05]`: this radio predates the advert.
List<int> advertUnsupported() => [
cmdOffbandDeviceUi,
offbandUiErr,
offbandUiErrReasonMalformed,
];
/// Connect a radio that ADVERTISES rotation, then answer the GET with
/// [rotation] degrees. Capability now comes from the tag, and the GET
/// reports the current value; the two are separate steps on purpose.
void connectWithRotation(int rotation, {int mode = 0}) {
connector.handleFrameForTest(deviceInfo(mode: mode));
connector.handleFrameForTest(
Uint8List.fromList(advert([offbandInfoTagDisplayRotation])),
);
connector.handleFrameForTest([
cmdOffbandDeviceUi,
offbandUiRotationGet,
rotation,
]);
sent.clear();
}
group('frames', () {
test('a mode SET is three bytes, with no rotation in it', () {
expect(buildDisplaySetFrame(DisplayMode.alwaysOff), [0xC5, 0x06, 2]);
});
test('rotation is 0x1A / 0x1B, and NEVER the CHNOTIFY pair', () {
// The collision, pinned at the wire. 0x18/0x19 against firmware
// 4e5a6c85 writes a channel-notify mode onto Public with a success
// reply on both sides.
expect(buildRotationGetFrame(), [0xC5, 0x1A]);
expect(buildRotationSetFrame(180), [0xC5, 0x1B, 180]);
expect(offbandUiRotationGet, isNot(0x18));
expect(offbandUiRotationSet, isNot(0x19));
});
test('a rotation other than 0 or 180 is refused before sending', () {
expect(() => buildRotationSetFrame(90), throwsArgumentError);
});
test('the LED SET is one byte', () {
expect(buildLedSetFrame(on: false), [0xC5, 0x08, 0]);
});
test('a rotation reply carries degrees', () {
final r = parseIndicatorReply(Uint8List.fromList([0xC5, 0x1B, 180]));
expect(r!.rotation, 180);
expect(r.surface, DeviceUiSurface.rotation);
expect(r.isWrite, isTrue);
});
test('an unknown mode or rotation is an error, not a guess', () {
final mode = parseIndicatorReply(Uint8List.fromList([0xC5, 0x05, 9]));
expect(mode!.errorMessage, contains('unknown display mode'));
final rot = parseIndicatorReply(Uint8List.fromList([0xC5, 0x1A, 90]));
expect(rot!.errorMessage, contains('unknown rotation'));
});
test('the indicator parser does not claim the CHNOTIFY pair', () {
// Belt and braces on the collision: even if a 0x18/0x19 frame reaches
// this parser, it must not be read as a rotation reply.
expect(parseIndicatorReply(Uint8List.fromList([0xC5, 0x18, 0])), isNull);
expect(parseIndicatorReply(Uint8List.fromList([0xC5, 0x19, 0])), isNull);
});
});
test('device-info bytes 85 and 86 render on connect', () {
connector.handleFrameForTest(deviceInfo(led: 0, mode: 2));
expect(connector.supportsIndicators, isTrue);
expect(connector.ledEnabled, isFalse);
expect(connector.displayMode, DisplayMode.alwaysOff);
});
test('a radio without 0x04 never sees an indicator frame', () async {
connector.handleFrameForTest(deviceInfo(caps2: 0x01));
await connector.setDisplayMode(DisplayMode.alwaysOn);
await connector.setLedEnabled(false);
expect(connector.supportsIndicators, isFalse);
expect(sentSub(offbandUiDisplaySet), isFalse);
expect(sentSub(offbandUiLedSet), isFalse);
expect(sentSub(offbandUiRotationGet), isFalse);
});
group('rotation', () {
test('the INDICATORS bit alone does NOT open rotation', () {
// The gate moved to the advert tag. 0x04 means indicators, which is a
// different capability; gating rotation on it was the old contract.
connector.handleFrameForTest(deviceInfo());
expect(connector.supportsIndicators, isTrue);
expect(connector.supportsDisplayRotation, isFalse);
expect(sentSub(offbandUiRotationGet), isFalse);
});
test('rotation is NOT probed, ever', () {
// Contract section 3: "what is forbidden is discovering support by
// trying each feature's sub-code and watching for errors." An advert
// without the tag must produce no 0x1A on the wire at all.
connector.handleFrameForTest(deviceInfo());
connector.handleFrameForTest(
Uint8List.fromList(advert([offbandInfoTagChanNotify])),
);
expect(connector.supportsDisplayRotation, isFalse);
expect(sentSub(offbandUiRotationGet), isFalse);
});
test('a radio that predates the advert gets no rotation and no frame', () {
connector.handleFrameForTest(deviceInfo());
connector.handleFrameForTest(Uint8List.fromList(advertUnsupported()));
expect(connector.supportsDisplayRotation, isFalse);
expect(sentSub(offbandUiRotationGet), isFalse);
expect(connector.deviceUiError, isNull, reason: 'not a failure to show');
});
test('the tag opens it, and the GET follows', () {
connector.handleFrameForTest(deviceInfo());
connector.handleFrameForTest(
Uint8List.fromList(advert([offbandInfoTagDisplayRotation])),
);
expect(connector.supportsDisplayRotation, isTrue);
expect(sentSub(offbandUiRotationGet), isTrue);
});
test(
'capability and value are separate: the tag does not imply a value',
() {
// The old gate was `supportsIndicators && _displayRotation != null`,
// which made "not answered yet" indistinguishable from "cannot rotate".
connector.handleFrameForTest(deviceInfo());
connector.handleFrameForTest(
Uint8List.fromList(advert([offbandInfoTagDisplayRotation])),
);
expect(connector.supportsDisplayRotation, isTrue);
expect(connector.displayRotation, isNull);
},
);
test('a byte past 86 in device-info is never read as rotation', () {
connector.handleFrameForTest(deviceInfo(extra: [180]));
expect(connector.supportsDisplayRotation, isFalse);
expect(connector.displayRotation, isNull);
});
test('the value arrives from the GET reply', () {
connectWithRotation(180);
expect(connector.supportsDisplayRotation, isTrue);
expect(connector.displayRotation, 180);
});
test('an advert refusal does not swallow the next write refusal', () {
connector.handleFrameForTest(deviceInfo());
connector.handleFrameForTest(Uint8List.fromList(advertUnsupported()));
connector.handleFrameForTest(
Uint8List.fromList(advert([offbandInfoTagDisplayRotation])),
);
sent.clear();
expect(connector.deviceUiError, isNull);
});
test('a rotation write before the tag sends nothing and says so', () async {
connector.handleFrameForTest(deviceInfo());
sent.clear();
await connector.setDisplayRotation(180);
expect(sent, isEmpty);
expect(connector.deviceUiErrorSurface, DeviceUiSurface.rotation);
expect(connector.deviceUiError, isNotNull);
});
test('a rotation write sends 0x1B and follows the echo', () async {
connectWithRotation(0);
await connector.setDisplayRotation(180);
expect(sent, [buildRotationSetFrame(180)]);
expect(connector.displayRotation, 0);
connector.handleFrameForTest([0xC5, 0x1B, 180]);
expect(connector.displayRotation, 180);
});
test(
'a rotation SET refused with reason 10 says so on its surface',
() async {
connectWithRotation(0);
await connector.setDisplayRotation(180);
connector.handleFrameForTest([0xC5, 0x7F, 0x0A]);
expect(connector.deviceUiErrorSurface, DeviceUiSurface.rotation);
expect(
connector.deviceUiError,
"This radio's display cannot be rotated",
);
expect(connector.displayRotation, 0);
},
);
});
test('a mode write never touches rotation', () async {
connectWithRotation(180);
await connector.setDisplayMode(DisplayMode.alwaysOff);
expect(sent, [buildDisplaySetFrame(DisplayMode.alwaysOff)]);
connector.handleFrameForTest([0xC5, 0x06, 2]);
expect(connector.displayMode, DisplayMode.alwaysOff);
expect(connector.displayRotation, 180);
});
test('state follows the echo, not the request', () async {
connectWithRotation(0);
await connector.setDisplayMode(DisplayMode.alwaysOn);
expect(connector.displayMode, DisplayMode.auto);
connector.handleFrameForTest([0xC5, 0x06, 1]);
expect(connector.displayMode, DisplayMode.alwaysOn);
connector.handleFrameForTest([0xC5, 0x08, 0]);
expect(connector.ledEnabled, isFalse);
});
test('a board without a display refuses on the display surface', () async {
connectWithRotation(0);
await connector.setDisplayMode(DisplayMode.alwaysOff);
connector.handleFrameForTest([0xC5, 0x7F, 0x06]);
expect(connector.deviceUiErrorSurface, DeviceUiSurface.display);
expect(connector.deviceUiError, 'This radio has no display or LED to set');
expect(connector.displayMode, DisplayMode.auto);
});
test('an LED refusal lands on the LED surface', () async {
connectWithRotation(0);
await connector.setLedEnabled(false);
connector.handleFrameForTest([0xC5, 0x7F, 0x06]);
expect(connector.deviceUiErrorSurface, DeviceUiSurface.led);
expect(connector.ledEnabled, isTrue);
});
test('a stock-shape frame reads no indicator bytes', () {
final stock = Uint8List(82);
stock[0] = 13;
stock[1] = 22;
connector.handleFrameForTest(stock);
expect(connector.supportsIndicators, isFalse);
expect(connector.displayMode, isNull);
expect(connector.ledEnabled, isNull);
});
}

@ -0,0 +1,95 @@
// Widget tests for the Display and LED pane (#523/#1098).
//
// These pin the crash the adversarial review caught before this was pushed:
// the rotation control force-unwrapped `displayRotation`, but the capability
// tag that ENABLES the control arrives in the `0xC5` `0x1C` advert, which lands
// BEFORE the rotation GET reply that supplies the value. So on any slow link
// the pane was built with the control enabled and the value still null, and it
// threw. The display-mode control beside it was already null-safe; only
// rotation was not.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/screens/settings/indicators_view.dart';
/// Rotation is advertised, but the radio has not answered the GET yet. This is
/// the real window between the advert reply and the rotation reply.
class _RotationUnanswered extends MeshCoreConnector {
@override
bool get supportsIndicators => true;
@override
bool get supportsDisplayRotation => true;
@override
int? get displayRotation => null;
}
class _RotationAnswered extends MeshCoreConnector {
_RotationAnswered(this._degrees);
final int _degrees;
@override
bool get supportsIndicators => true;
@override
bool get supportsDisplayRotation => true;
@override
int? get displayRotation => _degrees;
}
Future<void> _pump(WidgetTester tester, MeshCoreConnector connector) =>
tester.pumpWidget(
ChangeNotifierProvider<MeshCoreConnector>.value(
value: connector,
child: const MaterialApp(home: Scaffold(body: IndicatorsView())),
),
);
void main() {
testWidgets(
'the pane builds before the radio has answered the rotation GET',
(tester) async {
await _pump(tester, _RotationUnanswered());
// If this ever fails again, something is force-unwrapping a value that is
// null for the whole window between the advert and the GET reply.
expect(tester.takeException(), isNull);
expect(find.text('Rotation'), findsOneWidget);
},
);
testWidgets('no rotation is shown as chosen until the radio answers', (
tester,
) async {
await _pump(tester, _RotationUnanswered());
final button = tester.widget<SegmentedButton<int>>(
find.byType(SegmentedButton<int>),
);
// An empty selection, not a guessed default: showing "Normal" here would
// claim a rotation the radio never reported.
expect(button.selected, isEmpty);
expect(button.emptySelectionAllowed, isTrue);
});
testWidgets('the rotation the radio reported is the one shown', (
tester,
) async {
await _pump(tester, _RotationAnswered(180));
final button = tester.widget<SegmentedButton<int>>(
find.byType(SegmentedButton<int>),
);
expect(button.selected, {180});
});
testWidgets('a radio that does not advertise rotation gets no control', (
tester,
) async {
await _pump(tester, _DummyConnector());
expect(find.text('Rotation'), findsNothing);
expect(find.byType(SegmentedButton<int>), findsNothing);
});
}
class _DummyConnector extends MeshCoreConnector {}

@ -72,6 +72,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -440,6 +442,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -808,6 +812,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -1176,6 +1182,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -1544,6 +1552,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -1912,6 +1922,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -2280,6 +2292,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -2648,6 +2662,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -3016,6 +3032,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -3384,6 +3402,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -3752,6 +3772,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -4120,6 +4142,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -4488,6 +4512,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -4856,6 +4882,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -5224,6 +5252,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -5592,6 +5622,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",
@ -5960,6 +5992,8 @@
"settings_buttonActionsSubtitle",
"settings_badgeScreen",
"settings_badgeScreenSubtitle",
"settings_indicators",
"settings_indicatorsSubtitle",
"settings_badgeBattery",
"settings_badgeBatterySubtitle",
"settings_badgeBatteryReading",

Loading…
Cancel
Save

Powered by TurnKey Linux.