From a8ef70e37eac72214f29d20d0b58ed05a3972ad7 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:46:55 -0400 Subject: [PATCH 1/6] feat(#734): add the preset picker to the repeater and room server settings The remote-node radio card gains the same grouped RadioPresetPicker as Radio Settings. Picking a preset fills frequency, bandwidth, SF and CR and marks the radio field dirty, so Save sends the existing `set radio`. TX power is untouched: on these nodes it is a separate command. Room servers share this screen (contacts_screen.dart:1063), so they get it too. The selection is matched from the current fields to the nearest kHz (repeaters report e.g. 908.205017). The list refreshes when stale. Epic: #733 Agent: MagentaHawk (session 5a8922d6) Co-Authored-By: Claude Opus 5.5 --- lib/helpers/remote_radio_commands.dart | 34 ++++++ lib/screens/repeater_settings_screen.dart | 48 ++++++++ test/helpers/remote_radio_commands_test.dart | 109 +++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 lib/helpers/remote_radio_commands.dart create mode 100644 test/helpers/remote_radio_commands_test.dart diff --git a/lib/helpers/remote_radio_commands.dart b/lib/helpers/remote_radio_commands.dart new file mode 100644 index 00000000..9c691301 --- /dev/null +++ b/lib/helpers/remote_radio_commands.dart @@ -0,0 +1,34 @@ +import '../models/radio_preset.dart'; + +// Radio presets on remote nodes (repeater, room server, sensor) through the +// stock admin CLI (#733). Kept out of the screen so it can be tested. + +/// The preset whose radio values match the form's, or null for custom values. +/// The form holds frequency as text (the repeater reports e.g. "908.205017"), +/// so frequency matches to the nearest kHz. TX power is not compared: the +/// remote screen sets it separately. +String? matchRemotePresetId( + List presets, { + required String frequencyText, + required int? bandwidthHz, + required int? spreadingFactor, + required int? codingRate, +}) { + final mhz = double.tryParse(frequencyText.trim()); + if (mhz == null || + bandwidthHz == null || + spreadingFactor == null || + codingRate == null) { + return null; + } + final khz = (mhz * 1000).round(); + for (final p in presets) { + if (p.frequencyKHz == khz && + p.bandwidth.hz == bandwidthHz && + p.spreadingFactor.value == spreadingFactor && + p.codingRate.value == codingRate) { + return p.id; + } + } + return null; +} diff --git a/lib/screens/repeater_settings_screen.dart b/lib/screens/repeater_settings_screen.dart index 8e5c8922..42142c1d 100644 --- a/lib/screens/repeater_settings_screen.dart +++ b/lib/screens/repeater_settings_screen.dart @@ -6,9 +6,13 @@ import '../l10n/l10n.dart'; import '../models/contact.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../models/radio_preset.dart'; +import '../services/radio_preset_service.dart'; import '../services/repeater_command_service.dart'; import '../services/storage_service.dart'; import '../widgets/path_management_dialog.dart'; +import '../widgets/radio_preset_picker.dart'; +import '../helpers/remote_radio_commands.dart'; import '../helpers/snack_bar_builder.dart'; class RepeaterSettingsScreen extends StatefulWidget { @@ -185,8 +189,38 @@ class _RepeaterSettingsScreenState extends State { _commandService = RepeaterCommandService(connector); _setupMessageListener(); _loadSettings(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + Provider.of(context, listen: false).refreshIfStale(); + }); + } + + /// Fill the radio fields from a preset (#734). TX power is not part of a + /// remote preset: it is a separate command on these nodes. + void _applyPreset(RadioPreset preset) { + setState(() { + _freqController.text = preset.frequencyMHz.toStringAsFixed(3); + final bw = preset.bandwidth.hz; + if (!_bandwidthOptions.contains(bw)) { + _bandwidthOptions + ..add(bw) + ..sort(); + } + _bandwidth = bw; + _spreadingFactor = preset.spreadingFactor.value; + _codingRate = preset.codingRate.value; + }); + _markChanged(_SettingField.radio); } + String? _selectedPresetId(List presets) => matchRemotePresetId( + presets, + frequencyText: _freqController.text, + bandwidthHz: _bandwidth, + spreadingFactor: _spreadingFactor, + codingRate: _codingRate, + ); + @override void dispose() { _frameSubscription?.cancel(); @@ -1249,6 +1283,20 @@ class _RepeaterSettingsScreenState extends State { onRefresh: _refreshRadioSettings, ), const Divider(), + Consumer( + builder: (context, service, _) { + final presets = service.presets + .where((p) => !p.offGrid) + .toList(); + return RadioPresetPicker( + service: service, + presets: presets, + selectedId: _selectedPresetId(presets), + onSelected: _applyPreset, + ); + }, + ), + const SizedBox(height: 8), TextField( controller: _freqController, decoration: InputDecoration( diff --git a/test/helpers/remote_radio_commands_test.dart b/test/helpers/remote_radio_commands_test.dart new file mode 100644 index 00000000..c331a494 --- /dev/null +++ b/test/helpers/remote_radio_commands_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/helpers/remote_radio_commands.dart'; +import 'package:meshcore_open/models/radio_preset.dart'; +import 'package:meshcore_open/models/radio_settings.dart'; + +RadioPreset preset( + String id, { + double mhz = 908.205, + LoRaBandwidth bw = LoRaBandwidth.bw62_5, + LoRaSpreadingFactor sf = LoRaSpreadingFactor.sf9, + LoRaCodingRate cr = LoRaCodingRate.cr4_8, + int? tx, + int? hashBytes, +}) => RadioPreset( + id: id, + title: id, + region: 'USA', + frequencyMHz: mhz, + bandwidth: bw, + spreadingFactor: sf, + codingRate: cr, + source: RadioPresetSource.offband, + txPowerDbm: tx, + pathHashBytes: hashBytes, +); + +void main() { + group('matchRemotePresetId (#734)', () { + final presets = [ + preset('arizona', tx: 20), + preset( + 'oki', + mhz: 909.75, + bw: LoRaBandwidth.bw500, + sf: LoRaSpreadingFactor.sf10, + cr: LoRaCodingRate.cr4_5, + ), + ]; + + test('matches the frequency a repeater reports, to the nearest kHz', () { + expect( + matchRemotePresetId( + presets, + frequencyText: '908.205017', + bandwidthHz: 62500, + spreadingFactor: 9, + codingRate: 8, + ), + 'arizona', + ); + expect( + matchRemotePresetId( + presets, + frequencyText: '909.750', + bandwidthHz: 500000, + spreadingFactor: 10, + codingRate: 5, + ), + 'oki', + ); + }); + + test('any differing value means custom (null)', () { + expect( + matchRemotePresetId( + presets, + frequencyText: '908.205', + bandwidthHz: 62500, + spreadingFactor: 9, + codingRate: 5, + ), + isNull, + ); + expect( + matchRemotePresetId( + presets, + frequencyText: '908.300', + bandwidthHz: 62500, + spreadingFactor: 9, + codingRate: 8, + ), + isNull, + ); + }); + + test('unknown or unparsable fields match nothing', () { + expect( + matchRemotePresetId( + presets, + frequencyText: '', + bandwidthHz: 62500, + spreadingFactor: 9, + codingRate: 8, + ), + isNull, + ); + expect( + matchRemotePresetId( + presets, + frequencyText: '908.205', + bandwidthHz: null, + spreadingFactor: 9, + codingRate: 8, + ), + isNull, + ); + }); + }); +} From 5a6306c30f97b4e9ffd39a8f3485e8b8686725c2 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:49:29 -0400 Subject: [PATCH 2/6] feat(#662): add an "Apply temporarily" (tempradio) option to remote radio settings A checkbox on the remote-node radio card switches Save from `set radio` (persisted, applied after a reboot) to stock `tempradio` (applied now, reverting after the duration, nothing saved). The duration defaults to 1440 minutes (owner decision) and is limited to 1-35791: firmware schedules the revert at 2000 + minutes * 60000 ms in a 32-bit int, which overflows beyond that. Because tempradio retunes the node 2 s after it replies, it is sent after every other command in the same save. Coding rate goes out as the 4/x denominator, which is what firmware range-checks. Unchecked behaviour is unchanged. Epic: #733 Agent: MagentaHawk (session 5a8922d6) Co-Authored-By: Claude Opus 5.5 --- lib/helpers/remote_radio_commands.dart | 53 +++++++++++++++ lib/l10n/app_en.arb | 11 +++ lib/l10n/app_localizations.dart | 24 +++++++ lib/l10n/app_localizations_bg.dart | 15 +++++ lib/l10n/app_localizations_de.dart | 15 +++++ lib/l10n/app_localizations_en.dart | 15 +++++ lib/l10n/app_localizations_es.dart | 15 +++++ lib/l10n/app_localizations_fr.dart | 15 +++++ lib/l10n/app_localizations_hu.dart | 15 +++++ lib/l10n/app_localizations_it.dart | 15 +++++ lib/l10n/app_localizations_ja.dart | 15 +++++ lib/l10n/app_localizations_ko.dart | 15 +++++ lib/l10n/app_localizations_nl.dart | 15 +++++ lib/l10n/app_localizations_pl.dart | 15 +++++ lib/l10n/app_localizations_pt.dart | 15 +++++ lib/l10n/app_localizations_ru.dart | 15 +++++ lib/l10n/app_localizations_sk.dart | 15 +++++ lib/l10n/app_localizations_sl.dart | 15 +++++ lib/l10n/app_localizations_sv.dart | 15 +++++ lib/l10n/app_localizations_uk.dart | 15 +++++ lib/l10n/app_localizations_zh.dart | 15 +++++ lib/screens/repeater_settings_screen.dart | 70 ++++++++++++++++++-- test/helpers/remote_radio_commands_test.dart | 64 ++++++++++++++++++ untranslated.json | 68 +++++++++++++++++++ 24 files changed, 556 insertions(+), 4 deletions(-) diff --git a/lib/helpers/remote_radio_commands.dart b/lib/helpers/remote_radio_commands.dart index 9c691301..09d3fdee 100644 --- a/lib/helpers/remote_radio_commands.dart +++ b/lib/helpers/remote_radio_commands.dart @@ -3,6 +3,59 @@ import '../models/radio_preset.dart'; // Radio presets on remote nodes (repeater, room server, sensor) through the // stock admin CLI (#733). Kept out of the screen so it can be tested. +/// Default `tempradio` duration (owner decision on #662). +const int kTempRadioDefaultMinutes = 1440; + +/// Longest `tempradio` duration that works. Firmware schedules the revert at +/// `futureMillis(2000 + timeout_mins * 60 * 1000)` in a 32-bit int +/// (simple_repeater/MyMesh.cpp), which overflows past this. +const int kTempRadioMaxMinutes = 35791; + +String _bandwidthKHz(int bandwidthHz) => '${bandwidthHz / 1000}'; + +/// Stock CLI: persist radio params (applied after a reboot). +String setRadioCommand( + String frequencyText, + int bandwidthHz, + int spreadingFactor, + int codingRate, +) => + 'set radio $frequencyText,${_bandwidthKHz(bandwidthHz)},' + '$spreadingFactor,$codingRate'; + +/// Stock CLI: apply radio params now, reverting after [minutes]. Nothing is +/// saved on the node. Coding rate is the 4/x denominator (4/5 is 5), which +/// is what firmware range-checks (5-8). +String tempRadioCommand( + String frequencyText, + int bandwidthHz, + int spreadingFactor, + int codingRate, + int minutes, +) => + 'tempradio $frequencyText,${_bandwidthKHz(bandwidthHz)},' + '$spreadingFactor,$codingRate,$minutes'; + +/// A valid `tempradio` duration in minutes (1 to [kTempRadioMaxMinutes]), +/// or null. +int? parseTempRadioMinutes(String text) { + final minutes = int.tryParse(text.trim()); + if (minutes == null || minutes < 1 || minutes > kTempRadioMaxMinutes) { + return null; + } + return minutes; +} + +/// `tempradio` retunes the node 2 s after it replies, so anything sent after +/// it in the same save would go out on the old frequency. Keep the original +/// order otherwise, with retuning commands moved to the end. +List withRetuneLast(List commands, bool Function(T) retunes) => [ + ...commands.where((c) => !retunes(c)), + ...commands.where(retunes), +]; + +bool isRetuneCommand(String command) => command.startsWith('tempradio '); + /// The preset whose radio values match the form's, or null for custom values. /// The form holds frequency as text (the repeater reports e.g. "908.205017"), /// so frequency matches to the nearest kHz. TX power is not compared: the diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ae9d4130..1596c3f9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1459,6 +1459,17 @@ "repeater_bandwidth": "Bandwidth", "repeater_spreadingFactor": "Spreading Factor", "repeater_codingRate": "Coding Rate", + "repeater_tempRadio": "Apply temporarily", + "repeater_tempRadioSubtitle": "Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.", + "repeater_tempRadioMinutes": "Duration (minutes)", + "repeater_tempRadioMinutesInvalid": "1 to {max} minutes", + "@repeater_tempRadioMinutesInvalid": { + "placeholders": { + "max": { + "type": "int" + } + } + }, "repeater_locationSettings": "Location Settings", "repeater_latitude": "Latitude", "repeater_latitudeHelper": "Decimal degrees (e.g., 37.7749)", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 57001df4..9cc119a4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4688,6 +4688,30 @@ abstract class AppLocalizations { /// **'Coding Rate'** String get repeater_codingRate; + /// No description provided for @repeater_tempRadio. + /// + /// In en, this message translates to: + /// **'Apply temporarily'** + String get repeater_tempRadio; + + /// No description provided for @repeater_tempRadioSubtitle. + /// + /// In en, this message translates to: + /// **'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'** + String get repeater_tempRadioSubtitle; + + /// No description provided for @repeater_tempRadioMinutes. + /// + /// In en, this message translates to: + /// **'Duration (minutes)'** + String get repeater_tempRadioMinutes; + + /// No description provided for @repeater_tempRadioMinutesInvalid. + /// + /// In en, this message translates to: + /// **'1 to {max} minutes'** + String repeater_tempRadioMinutesInvalid(int max); + /// No description provided for @repeater_locationSettings. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 784e4a3e..c149c612 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -2654,6 +2654,21 @@ class AppLocalizationsBg extends AppLocalizations { @override String get repeater_codingRate => 'Такса за кодиране'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Настройки на местоположението'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 5b4d83e3..be34df21 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2649,6 +2649,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get repeater_codingRate => 'Kodierungsrate'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Standort Einstellungen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d52671e9..a0844267 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2605,6 +2605,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get repeater_codingRate => 'Coding Rate'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Location Settings'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index a39262f2..221be24a 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2646,6 +2646,21 @@ class AppLocalizationsEs extends AppLocalizations { @override String get repeater_codingRate => 'Tasa de Programación'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Configuración de Ubicación'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 40d259e3..383d5007 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2658,6 +2658,21 @@ class AppLocalizationsFr extends AppLocalizations { @override String get repeater_codingRate => 'Taux de codage (CR)'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Paramètres de localisation'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 62dc23ae..cff6bea4 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -2662,6 +2662,21 @@ class AppLocalizationsHu extends AppLocalizations { @override String get repeater_codingRate => 'Kódolási sebesség'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Helyszínbeállítások'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 9953f25d..255ab6ff 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -2650,6 +2650,21 @@ class AppLocalizationsIt extends AppLocalizations { @override String get repeater_codingRate => 'Tasso di Codifica'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Impostazioni Luogo'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 04572b59..08a93fa5 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2556,6 +2556,21 @@ class AppLocalizationsJa extends AppLocalizations { @override String get repeater_codingRate => 'コーディング速度'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => '場所設定'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 30753a94..95c7e807 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2553,6 +2553,21 @@ class AppLocalizationsKo extends AppLocalizations { @override String get repeater_codingRate => '코딩 속도'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => '위치 설정'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index c8007b6b..44edcf7d 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2633,6 +2633,21 @@ class AppLocalizationsNl extends AppLocalizations { @override String get repeater_codingRate => 'Codeertarief'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Locatie-instellingen'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index f744df37..f63bc565 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -2664,6 +2664,21 @@ class AppLocalizationsPl extends AppLocalizations { @override String get repeater_codingRate => 'Współczynnik kodowania'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Ustawienia Lokalizacji'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 6d8b8db1..bd820cbb 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2644,6 +2644,21 @@ class AppLocalizationsPt extends AppLocalizations { @override String get repeater_codingRate => 'Taxa de Codificação'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Configurações de Localização'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d851b30a..539cd83f 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2650,6 +2650,21 @@ class AppLocalizationsRu extends AppLocalizations { @override String get repeater_codingRate => 'Коэффициент кодирования'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Настройки местоположения'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 6c4b847c..616e8584 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -2636,6 +2636,21 @@ class AppLocalizationsSk extends AppLocalizations { @override String get repeater_codingRate => 'Rýchlosť kódovania'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Nastavenia polohy'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 11c4fe94..03f4a94f 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -2632,6 +2632,21 @@ class AppLocalizationsSl extends AppLocalizations { @override String get repeater_codingRate => 'Programska hitrost'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Nastavitve lokacije'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index a1a7b3b8..f9a7bff9 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -2621,6 +2621,21 @@ class AppLocalizationsSv extends AppLocalizations { @override String get repeater_codingRate => 'Kodningsgrad'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Platsinställningar'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index f51352f8..fe0be88c 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -2647,6 +2647,21 @@ class AppLocalizationsUk extends AppLocalizations { @override String get repeater_codingRate => 'Швидкість кодування'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => 'Налаштування геопозиції'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 485b30ee..0ce58567 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2512,6 +2512,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get repeater_codingRate => '编码速率'; + @override + String get repeater_tempRadio => 'Apply temporarily'; + + @override + String get repeater_tempRadioSubtitle => + 'Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.'; + + @override + String get repeater_tempRadioMinutes => 'Duration (minutes)'; + + @override + String repeater_tempRadioMinutesInvalid(int max) { + return '1 to $max minutes'; + } + @override String get repeater_locationSettings => '位置设置'; diff --git a/lib/screens/repeater_settings_screen.dart b/lib/screens/repeater_settings_screen.dart index 42142c1d..1ae733f5 100644 --- a/lib/screens/repeater_settings_screen.dart +++ b/lib/screens/repeater_settings_screen.dart @@ -127,6 +127,12 @@ class _RepeaterSettingsScreenState extends State { int? _spreadingFactor; int? _codingRate; + /// Apply the radio params with `tempradio` (reverting after the duration) + /// instead of persisting them with `set radio` (#662). + bool _tempRadio = false; + final TextEditingController _tempRadioMinutesController = + TextEditingController(text: '$kTempRadioDefaultMinutes'); + // Location settings final TextEditingController _latController = TextEditingController(); final TextEditingController _lonController = TextEditingController(); @@ -230,6 +236,7 @@ class _RepeaterSettingsScreenState extends State { _guestPasswordController.dispose(); _freqController.dispose(); _txPowerController.dispose(); + _tempRadioMinutesController.dispose(); _latController.dispose(); _lonController.dispose(); _ownerInfoController.dispose(); @@ -743,6 +750,22 @@ class _RepeaterSettingsScreenState extends State { final connector = Provider.of(context, listen: false); final repeater = _resolveRepeater(connector); + final tempRadioMinutes = parseTempRadioMinutes( + _tempRadioMinutesController.text, + ); + if (_tempRadio && + _dirtyFields.contains(_SettingField.radio) && + tempRadioMinutes == null) { + showDismissibleSnackBar( + context, + content: Text( + context.l10n.repeater_tempRadioMinutesInvalid(kTempRadioMaxMinutes), + ), + backgroundColor: Colors.red, + ); + return; + } + setState(() { _isLoading = true; }); @@ -784,11 +807,22 @@ class _RepeaterSettingsScreenState extends State { _codingRate != null) { final freqText = _freqController.text.trim(); if (double.tryParse(freqText) != null) { - final bwKHz = _bandwidth! / 1000; pending.add(( field: _SettingField.radio, - command: - 'set radio $freqText,$bwKHz,$_spreadingFactor,$_codingRate', + command: _tempRadio + ? tempRadioCommand( + freqText, + _bandwidth!, + _spreadingFactor!, + _codingRate!, + tempRadioMinutes!, + ) + : setRadioCommand( + freqText, + _bandwidth!, + _spreadingFactor!, + _codingRate!, + ), )); } } @@ -927,7 +961,10 @@ class _RepeaterSettingsScreenState extends State { final retainDirty = <_SettingField>{}; var passwordsFailed = false; var rebootNeeded = false; - for (final entry in pending) { + for (final entry in withRetuneLast( + pending, + (e) => isRetuneCommand(e.command), + )) { var failed = false; try { final response = await _commandService!.sendCommand( @@ -1395,6 +1432,31 @@ class _RepeaterSettingsScreenState extends State { } }, ), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(l10n.repeater_tempRadio), + subtitle: Text(l10n.repeater_tempRadioSubtitle), + value: _tempRadio, + onChanged: (value) { + setState(() => _tempRadio = value ?? false); + _markChanged(_SettingField.radio); + }, + ), + if (_tempRadio) + TextField( + controller: _tempRadioMinutesController, + decoration: InputDecoration( + labelText: l10n.repeater_tempRadioMinutes, + helperText: l10n.repeater_tempRadioMinutesInvalid( + kTempRadioMaxMinutes, + ), + border: const OutlineInputBorder(), + suffixText: 'min', + ), + keyboardType: TextInputType.number, + onChanged: (_) => _markChanged(_SettingField.radio), + ), const SizedBox(height: 8), _buildFeatureToggleRow( title: l10n.repeater_rxGain, diff --git a/test/helpers/remote_radio_commands_test.dart b/test/helpers/remote_radio_commands_test.dart index c331a494..2a6db480 100644 --- a/test/helpers/remote_radio_commands_test.dart +++ b/test/helpers/remote_radio_commands_test.dart @@ -25,6 +25,70 @@ RadioPreset preset( ); void main() { + group('remote radio commands (#662)', () { + test('set radio keeps its existing format', () { + expect( + setRadioCommand('908.205', 62500, 9, 8), + 'set radio 908.205,62.5,9,8', + ); + }); + + test('tempradio carries the values of the OKI-published command', () { + // docs.okimesh.org 500kHz-testing: tempradio 909.750,500,10,5,1440. + // "500.0" and "500" parse to the same bandwidth (firmware strtof, + // CommonCLI.cpp tempradio handler). + expect( + tempRadioCommand('909.750', 500000, 10, 5, kTempRadioDefaultMinutes), + 'tempradio 909.750,500.0,10,5,1440', + ); + }); + + test('coding rate goes out as the 4/x denominator', () { + expect(tempRadioCommand('910.525', 62500, 7, 5, 60), endsWith(',7,5,60')); + }); + + test('duration must be 1 to the firmware overflow bound', () { + expect(parseTempRadioMinutes('1440'), 1440); + expect(parseTempRadioMinutes(' 1 '), 1); + expect(parseTempRadioMinutes('$kTempRadioMaxMinutes'), 35791); + expect(parseTempRadioMinutes('${kTempRadioMaxMinutes + 1}'), isNull); + expect(parseTempRadioMinutes('0'), isNull); + expect(parseTempRadioMinutes('-5'), isNull); + expect(parseTempRadioMinutes('1.5'), isNull); + expect(parseTempRadioMinutes(''), isNull); + // 2000 + 35791 min fits a 32-bit int; one more minute does not. + expect(2000 + kTempRadioMaxMinutes * 60 * 1000, lessThan(1 << 31)); + expect( + 2000 + (kTempRadioMaxMinutes + 1) * 60 * 1000, + greaterThan(1 << 31), + ); + }); + + test('tempradio is sent after every other command', () { + final ordered = withRetuneLast([ + 'set name X', + 'tempradio 909.75,500.0,10,5,1440', + 'set tx 20', + 'set repeat on', + ], isRetuneCommand); + expect(ordered, [ + 'set name X', + 'set tx 20', + 'set repeat on', + 'tempradio 909.75,500.0,10,5,1440', + ]); + }); + + test('without tempradio the order is unchanged', () { + final commands = [ + 'set name X', + 'set radio 908.205,62.5,9,8', + 'set tx 20', + ]; + expect(withRetuneLast(commands, isRetuneCommand), commands); + }); + }); + group('matchRemotePresetId (#734)', () { final presets = [ preset('arizona', tx: 20), diff --git a/untranslated.json b/untranslated.json index e45cdf18..7da9f839 100644 --- a/untranslated.json +++ b/untranslated.json @@ -88,6 +88,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -309,6 +313,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -530,6 +538,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -751,6 +763,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -972,6 +988,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -1193,6 +1213,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -1414,6 +1438,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -1635,6 +1663,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -1856,6 +1888,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -2077,6 +2113,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -2298,6 +2338,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -2519,6 +2563,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -2740,6 +2788,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -2961,6 +3013,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -3182,6 +3238,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -3403,6 +3463,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", @@ -3624,6 +3688,10 @@ "map_alwaysShowNames", "path_suggestedRoute", "path_searchRepeaters", + "repeater_tempRadio", + "repeater_tempRadioSubtitle", + "repeater_tempRadioMinutes", + "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", "channelPath_routeFlood", From 4f465b9b63cc1b65c8a6f71524910b4684c576d3 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:51:28 -0400 Subject: [PATCH 3/6] feat(#735): apply a preset's path hash on repeaters and room servers Picking a preset that carries a path hash size sets the screen's existing path hash field to the zero-based mode, so Save sends `set path.hash.mode N` alongside the radio params. With "Apply temporarily" checked, a preset's path hash is held back: firmware has no temporary path hash, so it would outlive the tempradio revert and leave the node off its mesh. A note says so on screen, and the value stays pending for a normal save. A path hash the user sets by hand is always sent. (Default chosen here; flagged for the owner.) Epic: #733 Agent: MagentaHawk (session 5a8922d6) Co-Authored-By: Claude Opus 5.5 --- lib/helpers/remote_radio_commands.dart | 12 +++++ lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 +++ lib/l10n/app_localizations_bg.dart | 4 ++ lib/l10n/app_localizations_de.dart | 4 ++ lib/l10n/app_localizations_en.dart | 4 ++ lib/l10n/app_localizations_es.dart | 4 ++ lib/l10n/app_localizations_fr.dart | 4 ++ lib/l10n/app_localizations_hu.dart | 4 ++ lib/l10n/app_localizations_it.dart | 4 ++ lib/l10n/app_localizations_ja.dart | 4 ++ lib/l10n/app_localizations_ko.dart | 4 ++ lib/l10n/app_localizations_nl.dart | 4 ++ lib/l10n/app_localizations_pl.dart | 4 ++ lib/l10n/app_localizations_pt.dart | 4 ++ lib/l10n/app_localizations_ru.dart | 4 ++ lib/l10n/app_localizations_sk.dart | 4 ++ lib/l10n/app_localizations_sl.dart | 4 ++ lib/l10n/app_localizations_sv.dart | 4 ++ lib/l10n/app_localizations_uk.dart | 4 ++ lib/l10n/app_localizations_zh.dart | 4 ++ lib/screens/repeater_settings_screen.dart | 46 ++++++++++++++++++-- test/helpers/remote_radio_commands_test.dart | 38 ++++++++++++++++ untranslated.json | 17 ++++++++ 24 files changed, 189 insertions(+), 3 deletions(-) diff --git a/lib/helpers/remote_radio_commands.dart b/lib/helpers/remote_radio_commands.dart index 09d3fdee..bd929536 100644 --- a/lib/helpers/remote_radio_commands.dart +++ b/lib/helpers/remote_radio_commands.dart @@ -56,6 +56,18 @@ List withRetuneLast(List commands, bool Function(T) retunes) => [ bool isRetuneCommand(String command) => command.startsWith('tempradio '); +/// Stock CLI: path hash mode, zero-based (2 bytes = mode 1). +String setPathHashModeCommand(int mode) => 'set path.hash.mode $mode'; + +/// Whether a path hash that came from a preset goes out in this save (#735). +/// Firmware has no temporary path hash, so with a temporary radio change it +/// would outlive the revert; it stays pending for a normal save instead. A +/// path hash the user set by hand is always sent. +bool sendsPresetPathHash({ + required bool temporaryRadio, + required bool pathHashFromPreset, +}) => !(temporaryRadio && pathHashFromPreset); + /// The preset whose radio values match the form's, or null for custom values. /// The form holds frequency as text (the repeater reports e.g. "908.205017"), /// so frequency matches to the nearest kHz. TX power is not compared: the diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1596c3f9..cf7d8951 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1462,6 +1462,7 @@ "repeater_tempRadio": "Apply temporarily", "repeater_tempRadioSubtitle": "Uses tempradio: the node switches now and goes back to its saved radio settings after the duration. Nothing is saved on the node.", "repeater_tempRadioMinutes": "Duration (minutes)", + "repeater_tempRadioPathHashNote": "This preset's path hash size isn't applied with a temporary change: the node can't revert it on its own. It stays pending for a normal save.", "repeater_tempRadioMinutesInvalid": "1 to {max} minutes", "@repeater_tempRadioMinutesInvalid": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9cc119a4..7e647e98 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4706,6 +4706,12 @@ abstract class AppLocalizations { /// **'Duration (minutes)'** String get repeater_tempRadioMinutes; + /// No description provided for @repeater_tempRadioPathHashNote. + /// + /// In en, this message translates to: + /// **'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'** + String get repeater_tempRadioPathHashNote; + /// No description provided for @repeater_tempRadioMinutesInvalid. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index c149c612..b4d0af80 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -2664,6 +2664,10 @@ class AppLocalizationsBg extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index be34df21..a2b24de8 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2659,6 +2659,10 @@ class AppLocalizationsDe extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a0844267..8a92f96b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2615,6 +2615,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 221be24a..b5767cad 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2656,6 +2656,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 383d5007..468f59e8 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2668,6 +2668,10 @@ class AppLocalizationsFr extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index cff6bea4..f66ba0b6 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -2672,6 +2672,10 @@ class AppLocalizationsHu extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 255ab6ff..29f34ebf 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -2660,6 +2660,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 08a93fa5..3f9f4589 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2566,6 +2566,10 @@ class AppLocalizationsJa extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 95c7e807..f3a83da0 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2563,6 +2563,10 @@ class AppLocalizationsKo extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 44edcf7d..14485ab3 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2643,6 +2643,10 @@ class AppLocalizationsNl extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index f63bc565..ee7029b6 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -2674,6 +2674,10 @@ class AppLocalizationsPl extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index bd820cbb..2eed11ec 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2654,6 +2654,10 @@ class AppLocalizationsPt extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 539cd83f..3ae520f5 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2660,6 +2660,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 616e8584..f76a4910 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -2646,6 +2646,10 @@ class AppLocalizationsSk extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 03f4a94f..f1790e77 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -2642,6 +2642,10 @@ class AppLocalizationsSl extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index f9a7bff9..cb3a57a7 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -2631,6 +2631,10 @@ class AppLocalizationsSv extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index fe0be88c..35f6d098 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -2657,6 +2657,10 @@ class AppLocalizationsUk extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 0ce58567..e6f0768a 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2522,6 +2522,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get repeater_tempRadioMinutes => 'Duration (minutes)'; + @override + String get repeater_tempRadioPathHashNote => + 'This preset\'s path hash size isn\'t applied with a temporary change: the node can\'t revert it on its own. It stays pending for a normal save.'; + @override String repeater_tempRadioMinutesInvalid(int max) { return '1 to $max minutes'; diff --git a/lib/screens/repeater_settings_screen.dart b/lib/screens/repeater_settings_screen.dart index 1ae733f5..c7bd901f 100644 --- a/lib/screens/repeater_settings_screen.dart +++ b/lib/screens/repeater_settings_screen.dart @@ -160,6 +160,9 @@ class _RepeaterSettingsScreenState extends State { // Advanced int _pathHashMode = 0; // 0-2 + + /// The pending path hash came from a preset rather than the user (#735). + bool _pathHashFromPreset = false; final TextEditingController _txDelayController = TextEditingController(); final TextEditingController _directTxDelayController = TextEditingController(); @@ -215,8 +218,16 @@ class _RepeaterSettingsScreenState extends State { _bandwidth = bw; _spreadingFactor = preset.spreadingFactor.value; _codingRate = preset.codingRate.value; + final hashMode = preset.pathHashMode; + if (hashMode != null) { + _pathHashMode = hashMode; + _pathHashFromPreset = true; + } }); _markChanged(_SettingField.radio); + if (preset.pathHashMode != null) { + _markChanged(_SettingField.pathHashMode); + } } String? _selectedPresetId(List presets) => matchRemotePresetId( @@ -914,10 +925,15 @@ class _RepeaterSettingsScreenState extends State { command: 'set owner.info $encoded', )); } - if (_dirtyFields.contains(_SettingField.pathHashMode)) { + final holdPresetPathHash = !sendsPresetPathHash( + temporaryRadio: _tempRadio, + pathHashFromPreset: _pathHashFromPreset, + ); + if (_dirtyFields.contains(_SettingField.pathHashMode) && + !holdPresetPathHash) { pending.add(( field: _SettingField.pathHashMode, - command: 'set path.hash.mode $_pathHashMode', + command: setPathHashModeCommand(_pathHashMode), )); } if (_dirtyFields.contains(_SettingField.txDelay) && @@ -995,6 +1011,15 @@ class _RepeaterSettingsScreenState extends State { await Future.delayed(const Duration(milliseconds: 200)); } + // A preset path hash held back from a temporary save stays pending, + // so a later normal save can still apply it. + if (holdPresetPathHash && + _dirtyFields.contains(_SettingField.pathHashMode)) { + retainDirty.add(_SettingField.pathHashMode); + } else if (!retainDirty.contains(_SettingField.pathHashMode)) { + _pathHashFromPreset = false; + } + // Only clear password fields if every password command succeeded, // otherwise the user keeps their typed value to retry. if (!passwordsFailed) { @@ -1457,6 +1482,18 @@ class _RepeaterSettingsScreenState extends State { keyboardType: TextInputType.number, onChanged: (_) => _markChanged(_SettingField.radio), ), + if (_tempRadio && + _pathHashFromPreset && + _dirtyFields.contains(_SettingField.pathHashMode)) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + l10n.repeater_tempRadioPathHashNote, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), const SizedBox(height: 8), _buildFeatureToggleRow( title: l10n.repeater_rxGain, @@ -2086,7 +2123,10 @@ class _RepeaterSettingsScreenState extends State { ], onChanged: (v) { if (v != null) { - setState(() => _pathHashMode = v); + setState(() { + _pathHashMode = v; + _pathHashFromPreset = false; + }); _markChanged(_SettingField.pathHashMode); } }, diff --git a/test/helpers/remote_radio_commands_test.dart b/test/helpers/remote_radio_commands_test.dart index 2a6db480..8204b204 100644 --- a/test/helpers/remote_radio_commands_test.dart +++ b/test/helpers/remote_radio_commands_test.dart @@ -89,6 +89,44 @@ void main() { }); }); + group('preset path hash on remote nodes (#735)', () { + test('published bytes become the zero-based CLI mode', () { + final philly = preset('philly', hashBytes: 2); + final socal = preset('socal', hashBytes: 3); + expect( + setPathHashModeCommand(philly.pathHashMode!), + 'set path.hash.mode 1', + ); + expect( + setPathHashModeCommand(socal.pathHashMode!), + 'set path.hash.mode 2', + ); + expect(preset('usa').pathHashMode, isNull); + }); + + test('a preset path hash is held back from a temporary change', () { + expect( + sendsPresetPathHash(temporaryRadio: true, pathHashFromPreset: true), + isFalse, + ); + expect( + sendsPresetPathHash(temporaryRadio: false, pathHashFromPreset: true), + isTrue, + ); + }); + + test('a path hash set by hand is always sent', () { + expect( + sendsPresetPathHash(temporaryRadio: true, pathHashFromPreset: false), + isTrue, + ); + expect( + sendsPresetPathHash(temporaryRadio: false, pathHashFromPreset: false), + isTrue, + ); + }); + }); + group('matchRemotePresetId (#734)', () { final presets = [ preset('arizona', tx: 20), diff --git a/untranslated.json b/untranslated.json index 7da9f839..0317f9ad 100644 --- a/untranslated.json +++ b/untranslated.json @@ -91,6 +91,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -316,6 +317,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -541,6 +543,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -766,6 +769,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -991,6 +995,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -1216,6 +1221,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -1441,6 +1447,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -1666,6 +1673,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -1891,6 +1899,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -2116,6 +2125,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -2341,6 +2351,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -2566,6 +2577,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -2791,6 +2803,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -3016,6 +3029,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -3241,6 +3255,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -3466,6 +3481,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", @@ -3691,6 +3707,7 @@ "repeater_tempRadio", "repeater_tempRadioSubtitle", "repeater_tempRadioMinutes", + "repeater_tempRadioPathHashNote", "repeater_tempRadioMinutesInvalid", "repeater_cliCommandTimeout", "channelPath_routeDirect", From ba6f946272939b350233965e49d549cfc1d0d1b9 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:53:29 -0400 Subject: [PATCH 4/6] feat(#745): let a sensor be managed like a repeater Sensor contacts gain a "Manage Sensor" item that opens the repeater login and admin hub. Stock sensor firmware handles the same admin login (SensorMesh::handleLoginReq) and CLI-over-mesh as repeaters, so the remote settings screen, its preset picker and tempradio option work there too. Repeater and room server menus are unchanged. Epic: #733 Agent: MagentaHawk (session 5a8922d6) Co-Authored-By: Claude Opus 5.5 --- lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 ++++++ lib/l10n/app_localizations_bg.dart | 3 +++ lib/l10n/app_localizations_de.dart | 3 +++ lib/l10n/app_localizations_en.dart | 3 +++ lib/l10n/app_localizations_es.dart | 3 +++ lib/l10n/app_localizations_fr.dart | 3 +++ lib/l10n/app_localizations_hu.dart | 3 +++ lib/l10n/app_localizations_it.dart | 3 +++ lib/l10n/app_localizations_ja.dart | 3 +++ lib/l10n/app_localizations_ko.dart | 3 +++ lib/l10n/app_localizations_nl.dart | 3 +++ lib/l10n/app_localizations_pl.dart | 3 +++ lib/l10n/app_localizations_pt.dart | 3 +++ lib/l10n/app_localizations_ru.dart | 3 +++ lib/l10n/app_localizations_sk.dart | 3 +++ lib/l10n/app_localizations_sl.dart | 3 +++ lib/l10n/app_localizations_sv.dart | 3 +++ lib/l10n/app_localizations_uk.dart | 3 +++ lib/l10n/app_localizations_zh.dart | 3 +++ lib/screens/contacts_screen.dart | 14 ++++++++++++++ untranslated.json | 17 +++++++++++++++++ 22 files changed, 92 insertions(+) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cf7d8951..1134a965 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -541,6 +541,7 @@ } }, "contacts_manageRepeater": "Manage Repeater", + "contacts_manageSensor": "Manage Sensor", "contacts_manageRoom": "Manage Room Server", "contacts_roomLogin": "Room Server Login", "contacts_openChat": "Open Chat", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7e647e98..986178b4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2182,6 +2182,12 @@ abstract class AppLocalizations { /// **'Manage Repeater'** String get contacts_manageRepeater; + /// No description provided for @contacts_manageSensor. + /// + /// In en, this message translates to: + /// **'Manage Sensor'** + String get contacts_manageSensor; + /// No description provided for @contacts_manageRoom. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index b4d0af80..64b5d55e 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -1176,6 +1176,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get contacts_manageRepeater => 'Управление на Повтарящ се Елемент'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Управление на сървър за стая'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a2b24de8..a15d7ac0 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1171,6 +1171,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get contacts_manageRepeater => 'Repeater verwalten'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Raum-Server verwalten'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8a92f96b..57677e4f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1155,6 +1155,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contacts_manageRepeater => 'Manage Repeater'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Manage Room Server'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index b5767cad..4eec6a4b 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1170,6 +1170,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get contacts_manageRepeater => 'Gestionar Repetidor'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Gestionar Servidor de Habitación'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 468f59e8..ad6c704c 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1176,6 +1176,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get contacts_manageRepeater => 'Gérer le répéteur'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Gérer le Room Server'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index f66ba0b6..1a39cda4 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -1177,6 +1177,9 @@ class AppLocalizationsHu extends AppLocalizations { @override String get contacts_manageRepeater => 'Ellenőriző eszköz kezelése'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'A szobai szerver kezelése'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 29f34ebf..4505a900 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1172,6 +1172,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get contacts_manageRepeater => 'Gestisci Ripetitore'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Gestisci Server Camera'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 3f9f4589..c828620c 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1124,6 +1124,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get contacts_manageRepeater => 'リピーターの管理'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'ルームサーバーの管理'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index f3a83da0..94593ed4 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1119,6 +1119,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get contacts_manageRepeater => '리피터 관리'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => '방 서버 관리'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 14485ab3..3bf04ea4 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1165,6 +1165,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get contacts_manageRepeater => 'Beheer Repeater'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Beheer Ruimte Server'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index ee7029b6..c3640e5f 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -1182,6 +1182,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get contacts_manageRepeater => 'Zarządzaj przekaźnikiem'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Zarządzaj Serwerem Pokoju'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 2eed11ec..b6e9cfa1 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1172,6 +1172,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get contacts_manageRepeater => 'Gerenciar Repetidor'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Gerenciar Servidor de Sala'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 3ae520f5..400c0d67 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1173,6 +1173,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get contacts_manageRepeater => 'Управление репитером'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Управление сервером комнат'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index f76a4910..badbffd8 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -1162,6 +1162,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get contacts_manageRepeater => 'Spravovať opakované zoznamy'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Spravovať server miestnosti'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index f1790e77..56178ea9 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1161,6 +1161,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get contacts_manageRepeater => 'Upravljaj Ponovitve'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Upravljajte strežnik sobe'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index cb3a57a7..4a37cf3d 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -1156,6 +1156,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get contacts_manageRepeater => 'Hantera Upprepare'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Hantera Rumserver'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 35f6d098..160e9216 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1168,6 +1168,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get contacts_manageRepeater => 'Керувати ретранслятором'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => 'Керувати сервером кімнати'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e6f0768a..5dba1640 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1105,6 +1105,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get contacts_manageRepeater => '管理转发节点'; + @override + String get contacts_manageSensor => 'Manage Sensor'; + @override String get contacts_manageRoom => '管理房间服务器'; diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 379b29db..adc9899b 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -1509,6 +1509,20 @@ class _ContactsScreenState extends State }, ), ] else ...[ + // Sensors run the same admin login and CLI as repeaters + // (stock SensorMesh), so they share the repeater admin hub (#745). + if (contact.type == advTypeSensor) + ListTile( + leading: const Icon( + Icons.sensors_outlined, + color: Colors.orange, + ), + title: Text(context.l10n.contacts_manageSensor), + onTap: () { + Navigator.pop(sheetContext); + _showRepeaterLogin(context, contact); + }, + ), if (contact.pathLength > 0) ListTile( leading: const Icon(Icons.radar, color: Colors.green), diff --git a/untranslated.json b/untranslated.json index 0317f9ad..c21bd14b 100644 --- a/untranslated.json +++ b/untranslated.json @@ -55,6 +55,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -281,6 +282,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -507,6 +509,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -733,6 +736,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -959,6 +963,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -1185,6 +1190,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -1411,6 +1417,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -1637,6 +1644,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -1863,6 +1871,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -2089,6 +2098,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -2315,6 +2325,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -2541,6 +2552,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -2767,6 +2779,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -2993,6 +3006,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -3219,6 +3233,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -3445,6 +3460,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", @@ -3671,6 +3687,7 @@ "appSettings_clock12h", "appSettings_clock24h", "contacts_searchSensors", + "contacts_manageSensor", "channels_notifications", "channels_notifyAll", "channels_notifyMentionsOnly", From a65534c6fcfb2dc70cac5c3b4d9352f5406bdfc6 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:57:04 -0400 Subject: [PATCH 5/6] fix(#747): remote preset matching also checks the node's path hash mode Same gap as the companion form: a repeater on the USA values with a 1- or 2-byte path hash matched MeshCore's "Canada" (3-byte). The remote matcher now takes the node's path hash mode and applies the same rule. Epic: #733 Agent: MagentaHawk (session 5a8922d6) Co-Authored-By: Claude Opus 5.5 --- lib/helpers/remote_radio_commands.dart | 7 ++-- lib/screens/repeater_settings_screen.dart | 1 + test/helpers/remote_radio_commands_test.dart | 35 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lib/helpers/remote_radio_commands.dart b/lib/helpers/remote_radio_commands.dart index bd929536..06059e2e 100644 --- a/lib/helpers/remote_radio_commands.dart +++ b/lib/helpers/remote_radio_commands.dart @@ -71,13 +71,15 @@ bool sendsPresetPathHash({ /// The preset whose radio values match the form's, or null for custom values. /// The form holds frequency as text (the repeater reports e.g. "908.205017"), /// so frequency matches to the nearest kHz. TX power is not compared: the -/// remote screen sets it separately. +/// remote screen sets it separately. A preset with a path hash only matches +/// a node on that mode (#747): MeshCore's "Canada" and "USA" differ only there. String? matchRemotePresetId( List presets, { required String frequencyText, required int? bandwidthHz, required int? spreadingFactor, required int? codingRate, + required int pathHashMode, }) { final mhz = double.tryParse(frequencyText.trim()); if (mhz == null || @@ -91,7 +93,8 @@ String? matchRemotePresetId( if (p.frequencyKHz == khz && p.bandwidth.hz == bandwidthHz && p.spreadingFactor.value == spreadingFactor && - p.codingRate.value == codingRate) { + p.codingRate.value == codingRate && + presetPathHashMatches(p, pathHashMode + 1)) { return p.id; } } diff --git a/lib/screens/repeater_settings_screen.dart b/lib/screens/repeater_settings_screen.dart index c7bd901f..f1a87478 100644 --- a/lib/screens/repeater_settings_screen.dart +++ b/lib/screens/repeater_settings_screen.dart @@ -236,6 +236,7 @@ class _RepeaterSettingsScreenState extends State { bandwidthHz: _bandwidth, spreadingFactor: _spreadingFactor, codingRate: _codingRate, + pathHashMode: _pathHashMode, ); @override diff --git a/test/helpers/remote_radio_commands_test.dart b/test/helpers/remote_radio_commands_test.dart index 8204b204..5271f2b0 100644 --- a/test/helpers/remote_radio_commands_test.dart +++ b/test/helpers/remote_radio_commands_test.dart @@ -147,6 +147,7 @@ void main() { bandwidthHz: 62500, spreadingFactor: 9, codingRate: 8, + pathHashMode: 0, ), 'arizona', ); @@ -157,6 +158,7 @@ void main() { bandwidthHz: 500000, spreadingFactor: 10, codingRate: 5, + pathHashMode: 0, ), 'oki', ); @@ -170,6 +172,7 @@ void main() { bandwidthHz: 62500, spreadingFactor: 9, codingRate: 5, + pathHashMode: 0, ), isNull, ); @@ -180,6 +183,7 @@ void main() { bandwidthHz: 62500, spreadingFactor: 9, codingRate: 8, + pathHashMode: 0, ), isNull, ); @@ -193,6 +197,7 @@ void main() { bandwidthHz: 62500, spreadingFactor: 9, codingRate: 8, + pathHashMode: 0, ), isNull, ); @@ -203,9 +208,39 @@ void main() { bandwidthHz: null, spreadingFactor: 9, codingRate: 8, + pathHashMode: 0, ), isNull, ); }); + + test('a path hash preset only matches a node on that mode (#747)', () { + final usaPair = [ + preset( + 'canada', + mhz: 910.525, + sf: LoRaSpreadingFactor.sf7, + cr: LoRaCodingRate.cr4_5, + hashBytes: 3, + ), + preset( + 'usa', + mhz: 910.525, + sf: LoRaSpreadingFactor.sf7, + cr: LoRaCodingRate.cr4_5, + ), + ]; + String? match(int mode) => matchRemotePresetId( + usaPair, + frequencyText: '910.525', + bandwidthHz: 62500, + spreadingFactor: 7, + codingRate: 5, + pathHashMode: mode, + ); + expect(match(0), 'usa'); + expect(match(1), 'usa'); + expect(match(2), 'canada'); + }); }); } From 9c17f8a5dc8c021794267b6aa80d9b1d725e2568 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:32:10 -0400 Subject: [PATCH 6/6] test(#748): the default tempradio duration passes validation So "1 to N minutes" never shows as an error on an untouched form. Co-Authored-By: Claude Opus 5.5 --- test/helpers/remote_radio_commands_test.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/helpers/remote_radio_commands_test.dart b/test/helpers/remote_radio_commands_test.dart index 5271f2b0..f11eadd4 100644 --- a/test/helpers/remote_radio_commands_test.dart +++ b/test/helpers/remote_radio_commands_test.dart @@ -47,6 +47,16 @@ void main() { expect(tempRadioCommand('910.525', 62500, 7, 5, 60), endsWith(',7,5,60')); }); + test( + 'the default duration is valid, so no error shows by default (#748)', + () { + expect( + parseTempRadioMinutes('$kTempRadioDefaultMinutes'), + kTempRadioDefaultMinutes, + ); + }, + ); + test('duration must be 1 to the firmware overflow bound', () { expect(parseTempRadioMinutes('1440'), 1440); expect(parseTempRadioMinutes(' 1 '), 1);