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, + ); +}