From 6b6d9caeeb5d3370aded165c934619e6db46d614 Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Mon, 9 Mar 2026 18:29:17 -0400 Subject: [PATCH 1/4] Reapply "Fixed Preset on offgrid repeat toggle enhancemet #183" This reverts commit 758619bbaa6ce5895c7146bbfc3b89054e759527. --- lib/screens/settings_screen.dart | 55 +++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d9e0d209..a0dedac1 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1088,6 +1088,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { LoRaCodingRate _codingRate = LoRaCodingRate.cr4_5; final _txPowerController = TextEditingController(text: '20'); bool _clientRepeat = false; + int? _selectedPresetIndex; @override void initState() { @@ -1139,6 +1140,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { } _clientRepeat = widget.connector.clientRepeat ?? false; + _selectedPresetIndex = _findMatchingPresetIndex(); } @override @@ -1158,6 +1160,55 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { }); } + int? _findMatchingPresetIndex() { + final freqMHz = double.tryParse(_frequencyController.text); + final txPower = int.tryParse(_txPowerController.text); + if (freqMHz == null || txPower == null) return null; + + const epsilon = 0.001; + for (var i = 0; i < RadioSettings.presets.length; i++) { + final preset = RadioSettings.presets[i].$2; + if ((preset.frequencyMHz - freqMHz).abs() < epsilon && + preset.bandwidth == _bandwidth && + preset.spreadingFactor == _spreadingFactor && + preset.codingRate == _codingRate && + preset.txPowerDbm == txPower) { + return i; + } + } + return null; + } + + double _offGridFrequencyForBaseFrequency(double baseFrequencyMHz) { + if (baseFrequencyMHz < 500) return 433.0; + if (baseFrequencyMHz < 900) return 869.0; + return 918.0; + } + + double _normalFrequencyForBand(double frequencyMHz) { + if (frequencyMHz < 500) return 433.650; + if (frequencyMHz < 900) return 869.432; + return 915.8; + } + + void _handleClientRepeatChanged(bool enabled) { + setState(() { + _clientRepeat = enabled; + + final baseFrequencyMHz = _selectedPresetIndex != null + ? RadioSettings.presets[_selectedPresetIndex!].$2.frequencyMHz + : (double.tryParse(_frequencyController.text) ?? 915.0); + + final nextFrequencyMHz = enabled + ? _offGridFrequencyForBaseFrequency(baseFrequencyMHz) + : (_selectedPresetIndex != null + ? RadioSettings.presets[_selectedPresetIndex!].$2.frequencyMHz + : _normalFrequencyForBand(baseFrequencyMHz)); + + _frequencyController.text = nextFrequencyMHz.toStringAsFixed(3); + }); + } + Future _saveSettings() async { final l10n = context.l10n; final freqMHz = double.tryParse(_frequencyController.text); @@ -1250,6 +1301,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { crossAxisAlignment: CrossAxisAlignment.start, children: [ DropdownButtonFormField( + initialValue: _selectedPresetIndex, decoration: InputDecoration( labelText: l10n.settings_presets, border: const OutlineInputBorder(), @@ -1263,6 +1315,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { ], onChanged: (index) { if (index != null) { + _selectedPresetIndex = index; _applyPreset(RadioSettings.presets[index].$2); } }, @@ -1345,7 +1398,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { title: Text(l10n.settings_clientRepeat), subtitle: Text(l10n.settings_clientRepeatSubtitle), value: _clientRepeat, - onChanged: (value) => setState(() => _clientRepeat = value), + onChanged: _handleClientRepeatChanged, contentPadding: EdgeInsets.zero, ), ], From c9145c99d346548c2553f8b06de4f56c7aca0d86 Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Wed, 11 Mar 2026 11:18:35 -0400 Subject: [PATCH 2/4] fix(settings): preserve preset across off-grid repeat --- lib/connector/meshcore_connector.dart | 24 ++ lib/screens/settings_screen.dart | 383 +++++++++++++++++++++++--- 2 files changed, 374 insertions(+), 33 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 93c5dcd7..280b7e9d 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -102,6 +102,22 @@ class RepeaterBatterySnapshot { }); } +class MeshCoreRadioStateSnapshot { + final int freqHz; + final int bwHz; + final int sf; + final int cr; + final int txPowerDbm; + + const MeshCoreRadioStateSnapshot({ + required this.freqHz, + required this.bwHz, + required this.sf, + required this.cr, + required this.txPowerDbm, + }); +} + class MeshCoreConnector extends ChangeNotifier { // Message windowing to limit memory usage static const int _messageWindowSize = 200; @@ -167,6 +183,7 @@ class MeshCoreConnector extends ChangeNotifier { int? _currentSf; int? _currentCr; bool? _clientRepeat; + MeshCoreRadioStateSnapshot? _rememberedNonRepeatRadioState; int? _firmwareVerCode; int _pathHashByteWidth = 1; CompanionRadioStats? _latestRadioStats; @@ -366,6 +383,8 @@ class MeshCoreConnector extends ChangeNotifier { int? get currentBwHz => _currentBwHz; int? get currentSf => _currentSf; int? get currentCr => _currentCr; + MeshCoreRadioStateSnapshot? get rememberedNonRepeatRadioState => + _rememberedNonRepeatRadioState; bool? get autoAddUsers => _autoAddUsers; bool? get autoAddRepeaters => _autoAddRepeaters; bool? get autoAddRoomServers => _autoAddRoomServers; @@ -377,6 +396,10 @@ class MeshCoreConnector extends ChangeNotifier { int get advertLocationPolicy => _advertLocPolicy; int get multiAcks => _multiAcks; bool? get clientRepeat => _clientRepeat; + void rememberNonRepeatRadioState(MeshCoreRadioStateSnapshot snapshot) { + _rememberedNonRepeatRadioState = snapshot; + } + int? get firmwareVerCode => _firmwareVerCode; Map? get currentCustomVars => _currentCustomVars; int? get batteryMillivolts => _batteryMillivolts; @@ -2152,6 +2175,7 @@ class MeshCoreConnector extends ChangeNotifier { _selfLatitude = null; _selfLongitude = null; _clientRepeat = null; + _rememberedNonRepeatRadioState = null; _firmwareVerCode = null; _batteryMillivolts = null; _repeaterBatterySnapshots.clear(); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index a0dedac1..c90827b5 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -8,6 +8,7 @@ import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; import '../models/radio_settings.dart'; +import '../services/app_debug_log_service.dart'; import '../widgets/app_bar.dart'; import 'app_settings_screen.dart'; import 'app_debug_log_screen.dart'; @@ -1089,6 +1090,10 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { final _txPowerController = TextEditingController(text: '20'); bool _clientRepeat = false; int? _selectedPresetIndex; + _RadioSettingsSnapshot? _lastNonRepeatSnapshot; + + AppDebugLogService get _appLog => + Provider.of(context, listen: false); @override void initState() { @@ -1141,6 +1146,23 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { _clientRepeat = widget.connector.clientRepeat ?? false; _selectedPresetIndex = _findMatchingPresetIndex(); + _lastNonRepeatSnapshot = _currentSnapshot(); + if (_clientRepeat) { + _lastNonRepeatSnapshot = + _sessionRememberedNonRepeatSnapshot() ?? + _inferNonRepeatSnapshotForRepeatEnabled(); + _selectedPresetIndex = _findMatchingPresetIndexForSnapshot( + _lastNonRepeatSnapshot!, + ); + } else { + _lastNonRepeatSnapshot = + _sessionRememberedNonRepeatSnapshot() ?? + _nonRepeatSnapshotForCurrentSelection(); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _logRadioSettingsState('Dialog initialized'); + }); } @override @@ -1150,35 +1172,60 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { super.dispose(); } - void _applyPreset(RadioSettings preset) { + void _applyPreset(int index) { setState(() { - _frequencyController.text = preset.frequencyMHz.toString(); - _bandwidth = preset.bandwidth; - _spreadingFactor = preset.spreadingFactor; - _codingRate = preset.codingRate; - _txPowerController.text = preset.txPowerDbm.toString(); + _applyPresetState(index); }); + _logRadioSettingsState( + 'Applied preset ${RadioSettings.presets[index].$1} (#$index)', + ); } int? _findMatchingPresetIndex() { - final freqMHz = double.tryParse(_frequencyController.text); - final txPower = int.tryParse(_txPowerController.text); - if (freqMHz == null || txPower == null) return null; + return _findMatchingPresetIndexForSnapshot(_currentSnapshot()); + } + int? _findMatchingPresetIndexForSnapshot(_RadioSettingsSnapshot snapshot) { const epsilon = 0.001; - for (var i = 0; i < RadioSettings.presets.length; i++) { + for (final i in _visiblePresetIndexes()) { final preset = RadioSettings.presets[i].$2; - if ((preset.frequencyMHz - freqMHz).abs() < epsilon && - preset.bandwidth == _bandwidth && - preset.spreadingFactor == _spreadingFactor && - preset.codingRate == _codingRate && - preset.txPowerDbm == txPower) { + if ((preset.frequencyMHz - snapshot.frequencyMHz).abs() < epsilon && + preset.bandwidth == snapshot.bandwidth && + preset.spreadingFactor == snapshot.spreadingFactor && + preset.codingRate == snapshot.codingRate && + preset.txPowerDbm == snapshot.txPowerDbm) { return i; } } return null; } + Iterable _visiblePresetIndexes() sync* { + for (var i = 0; i < RadioSettings.presets.length; i++) { + if (_isOffGridPresetIndex(i)) { + continue; + } + yield i; + } + } + + _RadioSettingsSnapshot _currentSnapshot() { + final frequencyMHz = double.tryParse(_frequencyController.text) ?? 915.0; + final txPowerDbm = int.tryParse(_txPowerController.text) ?? 20; + return _RadioSettingsSnapshot( + frequencyMHz: frequencyMHz, + bandwidth: _bandwidth, + spreadingFactor: _spreadingFactor, + codingRate: _codingRate, + txPowerDbm: txPowerDbm, + ); + } + + bool _isOffGridPresetIndex(int? index) { + if (index == null) return false; + return RadioSettings.presets[index].$1.startsWith('Off-Grid '); + } + double _offGridFrequencyForBaseFrequency(double baseFrequencyMHz) { if (baseFrequencyMHz < 500) return 433.0; if (baseFrequencyMHz < 900) return 869.0; @@ -1191,22 +1238,182 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { return 915.8; } + _RadioSettingsSnapshot _fallbackNonRepeatSnapshot( + double currentFrequencyMHz, + ) { + return _RadioSettingsSnapshot( + frequencyMHz: _normalFrequencyForBand(currentFrequencyMHz), + bandwidth: _bandwidth, + spreadingFactor: _spreadingFactor, + codingRate: _codingRate, + txPowerDbm: int.tryParse(_txPowerController.text) ?? 20, + ); + } + + _RadioSettingsSnapshot _nonRepeatSnapshotForCurrentSelection() { + final current = _currentSnapshot(); + if (!_isOffGridPresetIndex(_selectedPresetIndex)) { + return current; + } + return _fallbackNonRepeatSnapshot(current.frequencyMHz); + } + + _RadioSettingsSnapshot? _sessionRememberedNonRepeatSnapshot() { + final snapshot = widget.connector.rememberedNonRepeatRadioState; + if (snapshot == null) { + return null; + } + + final bandwidth = LoRaBandwidth.values + .where((bw) => bw.hz == snapshot.bwHz) + .firstOrNull; + final spreadingFactor = LoRaSpreadingFactor.values + .where((sf) => sf.value == snapshot.sf) + .firstOrNull; + final codingRate = LoRaCodingRate.values + .where((cr) => cr.value == _toUiCodingRate(snapshot.cr)) + .firstOrNull; + + if (bandwidth == null || spreadingFactor == null || codingRate == null) { + return null; + } + + return _RadioSettingsSnapshot( + frequencyMHz: snapshot.freqHz / 1000.0, + bandwidth: bandwidth, + spreadingFactor: spreadingFactor, + codingRate: codingRate, + txPowerDbm: snapshot.txPowerDbm, + ); + } + + _RadioSettingsSnapshot _inferNonRepeatSnapshotForRepeatEnabled() { + final current = _currentSnapshot(); + const epsilon = 0.001; + for (final i in _visiblePresetIndexes()) { + final preset = RadioSettings.presets[i].$2; + final offGridFrequencyMHz = _offGridFrequencyForBaseFrequency( + preset.frequencyMHz, + ); + if ((offGridFrequencyMHz - current.frequencyMHz).abs() < epsilon && + preset.bandwidth == current.bandwidth && + preset.spreadingFactor == current.spreadingFactor && + preset.codingRate == current.codingRate && + preset.txPowerDbm == current.txPowerDbm) { + return _RadioSettingsSnapshot( + frequencyMHz: preset.frequencyMHz, + bandwidth: preset.bandwidth, + spreadingFactor: preset.spreadingFactor, + codingRate: preset.codingRate, + txPowerDbm: preset.txPowerDbm, + ); + } + } + return _fallbackNonRepeatSnapshot(current.frequencyMHz); + } + + void _applySnapshot(_RadioSettingsSnapshot snapshot) { + _frequencyController.text = snapshot.frequencyMHz.toStringAsFixed(3); + _bandwidth = snapshot.bandwidth; + _spreadingFactor = snapshot.spreadingFactor; + _codingRate = snapshot.codingRate; + _txPowerController.text = snapshot.txPowerDbm.toString(); + } + + void _applyPresetState(int index) { + final preset = RadioSettings.presets[index].$2; + final baseSnapshot = _RadioSettingsSnapshot( + frequencyMHz: preset.frequencyMHz, + bandwidth: preset.bandwidth, + spreadingFactor: preset.spreadingFactor, + codingRate: preset.codingRate, + txPowerDbm: preset.txPowerDbm, + ); + final frequencyMHz = _clientRepeat + ? _offGridFrequencyForBaseFrequency(baseSnapshot.frequencyMHz) + : baseSnapshot.frequencyMHz; + _frequencyController.text = frequencyMHz.toString(); + _bandwidth = preset.bandwidth; + _spreadingFactor = preset.spreadingFactor; + _codingRate = preset.codingRate; + _txPowerController.text = preset.txPowerDbm.toString(); + _selectedPresetIndex = index; + _lastNonRepeatSnapshot = baseSnapshot; + } + + void _syncPresetSelection() { + final previousPresetIndex = _selectedPresetIndex; + final previousLastNonRepeat = _lastNonRepeatSnapshot; + if (_clientRepeat) { + final baseSnapshot = + previousLastNonRepeat ?? _inferNonRepeatSnapshotForRepeatEnabled(); + if (_bandwidth != baseSnapshot.bandwidth || + _spreadingFactor != baseSnapshot.spreadingFactor || + _codingRate != baseSnapshot.codingRate || + (int.tryParse(_txPowerController.text) ?? 20) != + baseSnapshot.txPowerDbm) { + _lastNonRepeatSnapshot = _RadioSettingsSnapshot( + frequencyMHz: baseSnapshot.frequencyMHz, + bandwidth: _bandwidth, + spreadingFactor: _spreadingFactor, + codingRate: _codingRate, + txPowerDbm: int.tryParse(_txPowerController.text) ?? 20, + ); + } + _selectedPresetIndex = _findMatchingPresetIndexForSnapshot( + _lastNonRepeatSnapshot ?? baseSnapshot, + ); + if (previousPresetIndex != _selectedPresetIndex || + previousLastNonRepeat != _lastNonRepeatSnapshot) { + _logRadioSettingsState( + 'Preset match updated while repeat enabled: ${_presetLabel(previousPresetIndex)} -> ${_presetLabel(_selectedPresetIndex)}', + ); + } + return; + } + _lastNonRepeatSnapshot = _nonRepeatSnapshotForCurrentSelection(); + _selectedPresetIndex = _findMatchingPresetIndexForSnapshot( + _lastNonRepeatSnapshot!, + ); + if (previousPresetIndex != _selectedPresetIndex || + previousLastNonRepeat != _lastNonRepeatSnapshot) { + _logRadioSettingsState( + 'Preset sync updated state from ${_presetLabel(previousPresetIndex)} to ${_presetLabel(_selectedPresetIndex)}', + ); + } + } + + void _handleManualSettingsChanged(String source) { + _logRadioSettingsState('Manual settings edit: $source'); + setState(_syncPresetSelection); + } + void _handleClientRepeatChanged(bool enabled) { + _logRadioSettingsState( + 'Off-grid repeat toggle requested: $_clientRepeat -> $enabled', + ); setState(() { - _clientRepeat = enabled; + final currentSnapshot = _currentSnapshot(); + if (enabled) { + if (!_clientRepeat) { + _syncPresetSelection(); + } + final baseSnapshot = _lastNonRepeatSnapshot ?? currentSnapshot; + _clientRepeat = true; + _frequencyController.text = _offGridFrequencyForBaseFrequency( + baseSnapshot.frequencyMHz, + ).toStringAsFixed(3); + return; + } - final baseFrequencyMHz = _selectedPresetIndex != null - ? RadioSettings.presets[_selectedPresetIndex!].$2.frequencyMHz - : (double.tryParse(_frequencyController.text) ?? 915.0); - - final nextFrequencyMHz = enabled - ? _offGridFrequencyForBaseFrequency(baseFrequencyMHz) - : (_selectedPresetIndex != null - ? RadioSettings.presets[_selectedPresetIndex!].$2.frequencyMHz - : _normalFrequencyForBand(baseFrequencyMHz)); - - _frequencyController.text = nextFrequencyMHz.toStringAsFixed(3); + _clientRepeat = false; + _applySnapshot( + _lastNonRepeatSnapshot ?? + _fallbackNonRepeatSnapshot(currentSnapshot.frequencyMHz), + ); + _syncPresetSelection(); }); + _logRadioSettingsState('Off-grid repeat toggle applied'); } Future _saveSettings() async { @@ -1254,6 +1461,24 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { } try { + final rememberedSnapshot = _clientRepeat + ? _lastNonRepeatSnapshot + : _currentSnapshot(); + if (rememberedSnapshot != null) { + widget.connector.rememberNonRepeatRadioState( + MeshCoreRadioStateSnapshot( + freqHz: (rememberedSnapshot.frequencyMHz * 1000).round(), + bwHz: rememberedSnapshot.bandwidth.hz, + sf: rememberedSnapshot.spreadingFactor.value, + cr: _toDeviceCodingRate( + rememberedSnapshot.codingRate.value, + widget.connector.currentCr, + ), + txPowerDbm: rememberedSnapshot.txPowerDbm, + ), + ); + } + _logRadioSettingsState('Saving radio settings'); await widget.connector.sendFrame( buildSetRadioParamsFrame( freqHz, @@ -1268,10 +1493,12 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { if (!mounted) return; Navigator.pop(context); + _logRadioSettingsState('Radio settings saved successfully'); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_radioSettingsUpdated)), ); } catch (e) { + _appLog.warn('Radio settings save failed: $e', tag: 'RadioSettings'); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_error(e.toString()))), @@ -1290,6 +1517,39 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { return uiCr; } + String _presetLabel(int? index) { + if (index == null) { + return 'custom'; + } + return '${RadioSettings.presets[index].$1} (#$index)'; + } + + String _formatSnapshot(_RadioSettingsSnapshot? snapshot) { + if (snapshot == null) { + return 'null'; + } + return '${snapshot.frequencyMHz.toStringAsFixed(3)}MHz/' + '${snapshot.bandwidth.label}/' + '${snapshot.spreadingFactor.label}/' + '${snapshot.codingRate.label}/' + '${snapshot.txPowerDbm}dBm'; + } + + void _logRadioSettingsState(String message) { + _appLog.info( + '$message | ' + 'freq=${_frequencyController.text}MHz ' + 'bw=${_bandwidth.label} ' + 'sf=${_spreadingFactor.label} ' + 'cr=${_codingRate.label} ' + 'tx=${_txPowerController.text}dBm ' + 'repeat=$_clientRepeat ' + 'preset=${_presetLabel(_selectedPresetIndex)} ' + 'lastNonRepeat=${_formatSnapshot(_lastNonRepeatSnapshot)}', + tag: 'RadioSettings', + ); + } + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -1301,13 +1561,14 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { crossAxisAlignment: CrossAxisAlignment.start, children: [ DropdownButtonFormField( + key: ValueKey(_selectedPresetIndex), initialValue: _selectedPresetIndex, decoration: InputDecoration( labelText: l10n.settings_presets, border: const OutlineInputBorder(), ), items: [ - for (var i = 0; i < RadioSettings.presets.length; i++) + for (final i in _visiblePresetIndexes()) DropdownMenuItem( value: i, child: Text(RadioSettings.presets[i].$1), @@ -1315,14 +1576,14 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { ], onChanged: (index) { if (index != null) { - _selectedPresetIndex = index; - _applyPreset(RadioSettings.presets[index].$2); + _applyPreset(index); } }, ), const SizedBox(height: 16), TextField( controller: _frequencyController, + onChanged: (_) => _handleManualSettingsChanged('frequency'), decoration: InputDecoration( labelText: l10n.settings_frequency, border: const OutlineInputBorder(), @@ -1345,7 +1606,13 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { ) .toList(), onChanged: (value) { - if (value != null) setState(() => _bandwidth = value); + if (value != null) { + setState(() { + _bandwidth = value; + _syncPresetSelection(); + }); + _logRadioSettingsState('Manual settings edit: bandwidth'); + } }, ), const SizedBox(height: 16), @@ -1361,7 +1628,15 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { ) .toList(), onChanged: (value) { - if (value != null) setState(() => _spreadingFactor = value); + if (value != null) { + setState(() { + _spreadingFactor = value; + _syncPresetSelection(); + }); + _logRadioSettingsState( + 'Manual settings edit: spreading factor', + ); + } }, ), const SizedBox(height: 16), @@ -1377,12 +1652,19 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { ) .toList(), onChanged: (value) { - if (value != null) setState(() => _codingRate = value); + if (value != null) { + setState(() { + _codingRate = value; + _syncPresetSelection(); + }); + _logRadioSettingsState('Manual settings edit: coding rate'); + } }, ), const SizedBox(height: 16), TextField( controller: _txPowerController, + onChanged: (_) => _handleManualSettingsChanged('tx power'), decoration: InputDecoration( labelText: l10n.settings_txPower, border: const OutlineInputBorder(), @@ -1415,3 +1697,38 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { ); } } + +class _RadioSettingsSnapshot { + final double frequencyMHz; + final LoRaBandwidth bandwidth; + final LoRaSpreadingFactor spreadingFactor; + final LoRaCodingRate codingRate; + final int txPowerDbm; + + const _RadioSettingsSnapshot({ + required this.frequencyMHz, + required this.bandwidth, + required this.spreadingFactor, + required this.codingRate, + required this.txPowerDbm, + }); + + @override + bool operator ==(Object other) { + return other is _RadioSettingsSnapshot && + frequencyMHz == other.frequencyMHz && + bandwidth == other.bandwidth && + spreadingFactor == other.spreadingFactor && + codingRate == other.codingRate && + txPowerDbm == other.txPowerDbm; + } + + @override + int get hashCode => Object.hash( + frequencyMHz, + bandwidth, + spreadingFactor, + codingRate, + txPowerDbm, + ); +} From 36697c6e617adf151f5969bdcc6b2e34ca8f7a33 Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Wed, 11 Mar 2026 11:19:53 -0400 Subject: [PATCH 3/4] fix(settings): scope repeat preset memory to saved state --- lib/screens/settings_screen.dart | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c90827b5..44019dd8 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1155,9 +1155,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { _lastNonRepeatSnapshot!, ); } else { - _lastNonRepeatSnapshot = - _sessionRememberedNonRepeatSnapshot() ?? - _nonRepeatSnapshotForCurrentSelection(); + _lastNonRepeatSnapshot = _nonRepeatSnapshotForCurrentSelection(); } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -1461,6 +1459,18 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { } try { + _logRadioSettingsState('Saving radio settings'); + await widget.connector.sendFrame( + buildSetRadioParamsFrame( + freqHz, + bwHz, + sf, + cr, + clientRepeat: knownRepeat ? _clientRepeat : null, + ), + ); + await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower)); + await widget.connector.refreshDeviceInfo(); final rememberedSnapshot = _clientRepeat ? _lastNonRepeatSnapshot : _currentSnapshot(); @@ -1478,18 +1488,6 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { ), ); } - _logRadioSettingsState('Saving radio settings'); - await widget.connector.sendFrame( - buildSetRadioParamsFrame( - freqHz, - bwHz, - sf, - cr, - clientRepeat: knownRepeat ? _clientRepeat : null, - ), - ); - await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower)); - await widget.connector.refreshDeviceInfo(); if (!mounted) return; Navigator.pop(context); From 1e9508d4016c2ccb2bbf1d0a04fb23a5e0ae5c75 Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Sun, 15 Mar 2026 15:50:35 -0400 Subject: [PATCH 4/4] fix(settings): use integer Hz comparison, unify snapshot conversion, gate debug logging - Replace floating-point epsilon frequency comparison with integer Hz - Add frequencyHz getter and fromMeshCoreSnapshot/toMeshCoreSnapshot conversion methods on _RadioSettingsSnapshot - Move _toUiCodingRate/_toDeviceCodingRate to documented top-level functions - Gate _logRadioSettingsState behind kDebugMode - Use integer Hz in == and hashCode for _RadioSettingsSnapshot Addresses code review findings on preset/off-grid repeat toggle PR. --- lib/screens/settings_screen.dart | 119 +++++++++++++++++-------------- 1 file changed, 64 insertions(+), 55 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 44019dd8..e7d61ee7 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:meshcore_open/utils/gpx_export.dart'; import 'package:meshcore_open/widgets/elements_ui.dart'; @@ -15,6 +16,21 @@ import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; import '../widgets/radio_stats_entry.dart'; +/// Convert device coding-rate value (1-4 on some firmware, 5-8 on others) +/// to the UI enum range (always 5-8). +int _toUiCodingRate(int deviceCr) { + return deviceCr <= 4 ? deviceCr + 4 : deviceCr; +} + +/// Convert UI coding-rate value (5-8) back to firmware encoding. +/// Uses the current device CR to detect which encoding the firmware expects. +int _toDeviceCodingRate(int uiCr, int? deviceCr) { + if (deviceCr != null && deviceCr <= 4) { + return uiCr - 4; + } + return uiCr; +} + class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -1184,10 +1200,9 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { } int? _findMatchingPresetIndexForSnapshot(_RadioSettingsSnapshot snapshot) { - const epsilon = 0.001; for (final i in _visiblePresetIndexes()) { final preset = RadioSettings.presets[i].$2; - if ((preset.frequencyMHz - snapshot.frequencyMHz).abs() < epsilon && + if (preset.frequencyHz == snapshot.frequencyHz && preset.bandwidth == snapshot.bandwidth && preset.spreadingFactor == snapshot.spreadingFactor && preset.codingRate == snapshot.codingRate && @@ -1258,42 +1273,18 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { _RadioSettingsSnapshot? _sessionRememberedNonRepeatSnapshot() { final snapshot = widget.connector.rememberedNonRepeatRadioState; - if (snapshot == null) { - return null; - } - - final bandwidth = LoRaBandwidth.values - .where((bw) => bw.hz == snapshot.bwHz) - .firstOrNull; - final spreadingFactor = LoRaSpreadingFactor.values - .where((sf) => sf.value == snapshot.sf) - .firstOrNull; - final codingRate = LoRaCodingRate.values - .where((cr) => cr.value == _toUiCodingRate(snapshot.cr)) - .firstOrNull; - - if (bandwidth == null || spreadingFactor == null || codingRate == null) { - return null; - } - - return _RadioSettingsSnapshot( - frequencyMHz: snapshot.freqHz / 1000.0, - bandwidth: bandwidth, - spreadingFactor: spreadingFactor, - codingRate: codingRate, - txPowerDbm: snapshot.txPowerDbm, - ); + if (snapshot == null) return null; + return _RadioSettingsSnapshot.fromMeshCoreSnapshot(snapshot); } _RadioSettingsSnapshot _inferNonRepeatSnapshotForRepeatEnabled() { final current = _currentSnapshot(); - const epsilon = 0.001; for (final i in _visiblePresetIndexes()) { final preset = RadioSettings.presets[i].$2; - final offGridFrequencyMHz = _offGridFrequencyForBaseFrequency( - preset.frequencyMHz, - ); - if ((offGridFrequencyMHz - current.frequencyMHz).abs() < epsilon && + final offGridFreqHz = + (_offGridFrequencyForBaseFrequency(preset.frequencyMHz) * 1000) + .round(); + if (offGridFreqHz == current.frequencyHz && preset.bandwidth == current.bandwidth && preset.spreadingFactor == current.spreadingFactor && preset.codingRate == current.codingRate && @@ -1476,16 +1467,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { : _currentSnapshot(); if (rememberedSnapshot != null) { widget.connector.rememberNonRepeatRadioState( - MeshCoreRadioStateSnapshot( - freqHz: (rememberedSnapshot.frequencyMHz * 1000).round(), - bwHz: rememberedSnapshot.bandwidth.hz, - sf: rememberedSnapshot.spreadingFactor.value, - cr: _toDeviceCodingRate( - rememberedSnapshot.codingRate.value, - widget.connector.currentCr, - ), - txPowerDbm: rememberedSnapshot.txPowerDbm, - ), + rememberedSnapshot.toMeshCoreSnapshot(widget.connector.currentCr), ); } @@ -1504,17 +1486,6 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { } } - int _toUiCodingRate(int deviceCr) { - return deviceCr <= 4 ? deviceCr + 4 : deviceCr; - } - - int _toDeviceCodingRate(int uiCr, int? deviceCr) { - if (deviceCr != null && deviceCr <= 4) { - return uiCr - 4; - } - return uiCr; - } - String _presetLabel(int? index) { if (index == null) { return 'custom'; @@ -1534,6 +1505,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { } void _logRadioSettingsState(String message) { + if (!kDebugMode) return; _appLog.info( '$message | ' 'freq=${_frequencyController.text}MHz ' @@ -1711,10 +1683,47 @@ class _RadioSettingsSnapshot { required this.txPowerDbm, }); + /// Frequency in integer Hz — avoids floating-point comparison issues. + int get frequencyHz => (frequencyMHz * 1000).round(); + + /// Convert from the connector's raw-int snapshot to UI-enum snapshot. + static _RadioSettingsSnapshot? fromMeshCoreSnapshot( + MeshCoreRadioStateSnapshot snapshot, + ) { + final bw = LoRaBandwidth.values + .where((b) => b.hz == snapshot.bwHz) + .firstOrNull; + final sf = LoRaSpreadingFactor.values + .where((s) => s.value == snapshot.sf) + .firstOrNull; + final cr = LoRaCodingRate.values + .where((c) => c.value == _toUiCodingRate(snapshot.cr)) + .firstOrNull; + if (bw == null || sf == null || cr == null) return null; + return _RadioSettingsSnapshot( + frequencyMHz: snapshot.freqHz / 1000.0, + bandwidth: bw, + spreadingFactor: sf, + codingRate: cr, + txPowerDbm: snapshot.txPowerDbm, + ); + } + + /// Convert back to the connector's raw-int snapshot. + MeshCoreRadioStateSnapshot toMeshCoreSnapshot(int? deviceCr) { + return MeshCoreRadioStateSnapshot( + freqHz: frequencyHz, + bwHz: bandwidth.hz, + sf: spreadingFactor.value, + cr: _toDeviceCodingRate(codingRate.value, deviceCr), + txPowerDbm: txPowerDbm, + ); + } + @override bool operator ==(Object other) { return other is _RadioSettingsSnapshot && - frequencyMHz == other.frequencyMHz && + frequencyHz == other.frequencyHz && bandwidth == other.bandwidth && spreadingFactor == other.spreadingFactor && codingRate == other.codingRate && @@ -1723,7 +1732,7 @@ class _RadioSettingsSnapshot { @override int get hashCode => Object.hash( - frequencyMHz, + frequencyHz, bandwidth, spreadingFactor, codingRate,