From cf0f7a42a7e17c25142b2a29928b84ea2c657a0d Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:15:08 +0000 Subject: [PATCH] fix(lora): validate 2.4 GHz bandwidth options (#6529) Co-authored-by: James Rich Co-authored-by: Claude Fable 5 --- .skills/compose-ui/strings-index.txt | 5 + .../composeResources/values/strings.xml | 5 + .../core/ui/component/PreferenceFooter.kt | 3 +- feature/settings/settings-validation.md | 2 +- .../settings/radio/RadioConfigViewModel.kt | 15 +- .../radio/component/LoRaBandwidthPolicy.kt | 81 +++++++++ .../radio/component/LoRaConfigItemList.kt | 80 ++++++++- .../radio/component/RadioConfigScreenList.kt | 4 +- .../radio/RadioConfigViewModelTest.kt | 64 ++++++- .../component/LoRaBandwidthPolicyTest.kt | 163 ++++++++++++++++++ .../radio/component/LoRaBandwidthUiTest.kt | 96 +++++++++++ 11 files changed, 506 insertions(+), 12 deletions(-) create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaBandwidthPolicy.kt create mode 100644 feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LoRaBandwidthPolicyTest.kt create mode 100644 feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LoRaBandwidthUiTest.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 87a783cada..8241a8f0e4 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -114,7 +114,12 @@ backup_keys backup_keys_confirmation backup_restore bad +### BANDWIDTH ### bandwidth +bandwidth_default +bandwidth_option_khz +bandwidth_unsupported +bandwidth_unsupported_summary baro_pressure battery battery_ina_2xx_i2c_address diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 5ad9df2224..affb5fbe8a 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -132,7 +132,12 @@ Saves the public and private keys to secure, encrypted storage on this device. Backup & Restore Bad + Bandwidth + Default (%1$s kHz) + %1$s kHz + Unsupported (%1$s) + This bandwidth is not supported by the connected radio in the selected region. Choose a supported value before saving. Baro Battery Battery INA_2XX I2C address diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt index ddc4c2c178..a52a4d7ae2 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PreferenceFooter.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp fun PreferenceFooter( modifier: Modifier = Modifier, enabled: Boolean = true, + positiveEnabled: Boolean = true, negativeText: String? = null, onNegativeClicked: () -> Unit = {}, positiveText: String? = null, @@ -64,7 +65,7 @@ fun PreferenceFooter( shapes = ButtonDefaults.shapesFor(mediumHeight), modifier = Modifier.height(mediumHeight).weight(1f), colors = ButtonDefaults.buttonColors(), - enabled = enabled, + enabled = enabled && positiveEnabled, onClick = onPositiveClicked, ) { Text(text = positiveText, style = ButtonDefaults.textStyleFor(mediumHeight)) diff --git a/feature/settings/settings-validation.md b/feature/settings/settings-validation.md index c6cda37306..6fcaf350c3 100644 --- a/feature/settings/settings-validation.md +++ b/feature/settings/settings-validation.md @@ -144,7 +144,7 @@ configuration settings screen. Constraints are sourced from two layers: | `region` | Enum | Dropdown: `RegionInfo` entries | Regional frequency plans | | `use_preset` | Boolean | Toggle | Controls manual vs preset LoRa settings visibility | | `modem_preset` | Enum | Dropdown: `ChannelOption` entries | Visible only when `use_preset = true` | -| `bandwidth` | Integer | Numeric input | Visible only when `use_preset = false` | +| `bandwidth` | Integer | Numeric input outside `LORA_24`; region/target-aware dropdown in `LORA_24` | Visible only when `use_preset = false`; protobuf default `0` is shown as the firmware's 812.5 kHz `LORA_24` default, while unsupported nonzero values remain visible and block Save until replaced | | `spread_factor` | Integer | Numeric input | Visible only when `use_preset = false` | | `coding_rate` | Integer | Numeric input | Visible only when `use_preset = false` | | `hop_limit` | Integer | Dropdown: 0–7 | — | diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 4da4940209..4a74c5ac61 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -116,6 +116,8 @@ internal val MANUAL_CHANNEL_WRITE_DELAY: Duration = 1.seconds /** Data class that represents the current RadioConfig state. */ data class RadioConfigState( val isLocal: Boolean = false, + /** PlatformIO target for the configured destination; available only when that destination is directly connected. */ + val pioEnv: String? = null, val connected: Boolean = false, val route: String = "", val metadata: DeviceMetadata? = null, @@ -302,10 +304,10 @@ open class RadioConfigViewModel( nodeRepository.myNodeInfo .map { ni -> val isLocal = (destNum == null) || (destNum == ni?.myNodeNum) - isLocal + isLocal to if (isLocal) ni?.pioEnv else null } .distinctUntilChanged() - .flatMapLatest { isLocal -> + .flatMapLatest { (isLocal, pioEnv) -> if (isLocal) { combine( radioConfigRepository.channelSetFlow, @@ -313,7 +315,13 @@ open class RadioConfigViewModel( radioConfigRepository.moduleConfigFlow, ) { cs, lc, mc -> _radioConfigState.update { - it.copy(isLocal = true, channelList = cs.settings, radioConfig = lc, moduleConfig = mc) + it.copy( + isLocal = true, + pioEnv = pioEnv, + channelList = cs.settings, + radioConfig = lc, + moduleConfig = mc, + ) } } } else { @@ -323,6 +331,7 @@ open class RadioConfigViewModel( _radioConfigState.update { it.copy( isLocal = false, + pioEnv = null, channelList = emptyList(), radioConfig = LocalConfig(), moduleConfig = LocalModuleConfig(), diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaBandwidthPolicy.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaBandwidthPolicy.kt new file mode 100644 index 0000000000..62e2219cc1 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaBandwidthPolicy.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import org.meshtastic.proto.Config.LoRaConfig.RegionCode +import org.meshtastic.proto.HardwareModel + +internal data class LoRaBandwidthOption(val wireValue: Int, val displayKilohertz: String) + +internal data class LoRaBandwidthSelection( + /** Null outside LORA_24, where Android's existing numeric input remains unconstrained. */ + val options: List?, + /** An unsupported stored value that the UI must keep visible until the user replaces it. */ + val invalidPersistedValue: Int?, +) { + val isValid: Boolean + get() = invalidPersistedValue == null + + fun allowsSave(usePreset: Boolean): Boolean = usePreset || isValid +} + +private const val SX128X_WIDEST_BANDWIDTH_CODE = 1600 + +private val LORA_24_DEFAULT_OPTION = LoRaBandwidthOption(0, "812.5") + +private val CONSERVATIVE_HIGH_BAND_OPTIONS = + listOf(LoRaBandwidthOption(200, "203.125"), LoRaBandwidthOption(400, "406.25"), LoRaBandwidthOption(800, "812.5")) + +private val SX128X_ONLY_TARGETS = + setOf( + "betafpv_2400_tx_micro", + "makerpython_nrf52840_sx1280_eink", + "makerpython_nrf52840_sx1280_oled", + "my-esp32s3-diy-eink", + "my-esp32s3-diy-oled", + "tlora-v2-1-1_8", + ) + +private val SX128X_EXCLUDED_HARDWARE_MODELS = setOf(HardwareModel.TLORA_T3_S3, HardwareModel.MUZI_BASE) + +/** + * Returns the custom-bandwidth policy for the destination radio. + * + * Current protobufs report a PlatformIO target only for the directly connected node and do not report the radio chip + * selected at runtime. Therefore 1600 is granted only to exact firmware targets that compile exclusively for SX128x. + * Mixed, remote, and unknown targets use the LR1121-safe conservative set. + */ +internal fun loRaBandwidthSelection( + storedValue: Int, + region: RegionCode, + hwModel: HardwareModel?, + pioEnv: String?, +): LoRaBandwidthSelection { + if (region != RegionCode.LORA_24) return LoRaBandwidthSelection(options = null, invalidPersistedValue = null) + + val sx128xOnly = + hwModel !in SX128X_EXCLUDED_HARDWARE_MODELS && pioEnv?.lowercase()?.let(SX128X_ONLY_TARGETS::contains) == true + val supportedOptions = + if (sx128xOnly) { + CONSERVATIVE_HIGH_BAND_OPTIONS + LoRaBandwidthOption(SX128X_WIDEST_BANDWIDTH_CODE, "1625") + } else { + CONSERVATIVE_HIGH_BAND_OPTIONS + } + val options = listOf(LORA_24_DEFAULT_OPTION) + supportedOptions + val invalidPersistedValue = storedValue.takeUnless { value -> options.any { it.wireValue == value } } + return LoRaBandwidthSelection(options = options, invalidPersistedValue = invalidPersistedValue) +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt index a2878e6bac..1cf74964b8 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt @@ -39,6 +39,10 @@ import org.meshtastic.core.model.repairPresetFor import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.advanced import org.meshtastic.core.resources.bandwidth +import org.meshtastic.core.resources.bandwidth_default +import org.meshtastic.core.resources.bandwidth_option_khz +import org.meshtastic.core.resources.bandwidth_unsupported +import org.meshtastic.core.resources.bandwidth_unsupported_summary import org.meshtastic.core.resources.coding_rate import org.meshtastic.core.resources.config_lora_frequency_slot_summary import org.meshtastic.core.resources.config_lora_hop_limit_summary @@ -133,12 +137,21 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val primaryChannel = remember(formState.value) { Channel(primarySettings, formState.value) } val focusManager = LocalFocusManager.current + val bandwidthSelection = + loRaBandwidthSelection( + storedValue = formState.value.bandwidth, + region = formState.value.region, + hwModel = state.metadata?.hw_model, + pioEnv = state.pioEnv, + ) + val customBandwidthIsValid = bandwidthSelection.allowsSave(formState.value.use_preset) RadioConfigScreenList( title = stringResource(Res.string.lora), onBack = onBack, configState = formState, enabled = state.connected, + saveEnabled = customBandwidthIsValid, responseState = state.responseState, onDismissPacketResponse = viewModel::clearPacketResponse, onSave = { @@ -222,6 +235,7 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { } else { ManualModemSettings( config = formState.value, + bandwidthSelection = bandwidthSelection, enabled = state.connected, focusManager = focusManager, onConfigChange = { formState.value = it }, @@ -342,17 +356,18 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { @Composable private fun ManualModemSettings( config: Config.LoRaConfig, + bandwidthSelection: LoRaBandwidthSelection, enabled: Boolean, focusManager: androidx.compose.ui.focus.FocusManager, onConfigChange: (Config.LoRaConfig) -> Unit, ) { androidx.compose.foundation.layout.Column { - EditTextPreference( - title = stringResource(Res.string.bandwidth), - value = config.bandwidth, + LoRaBandwidthPreference( + config = config, + selection = bandwidthSelection, enabled = enabled, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { onConfigChange(config.copy(bandwidth = it)) }, + focusManager = focusManager, + onConfigChange = onConfigChange, ) HorizontalDivider() EditTextPreference( @@ -382,3 +397,58 @@ private fun ManualModemSettings( ) } } + +@Composable +internal fun LoRaBandwidthPreference( + config: Config.LoRaConfig, + selection: LoRaBandwidthSelection, + enabled: Boolean, + focusManager: androidx.compose.ui.focus.FocusManager, + onConfigChange: (Config.LoRaConfig) -> Unit, +) { + val options = selection.options + if (options == null) { + EditTextPreference( + title = stringResource(Res.string.bandwidth), + value = config.bandwidth, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { onConfigChange(config.copy(bandwidth = it)) }, + ) + return + } + + val items = + options + .map { option -> + DropDownItem( + value = option.wireValue, + label = + if (option.wireValue == 0) { + stringResource(Res.string.bandwidth_default, option.displayKilohertz) + } else { + stringResource(Res.string.bandwidth_option_khz, option.displayKilohertz) + }, + ) + } + .toMutableList() + selection.invalidPersistedValue?.let { invalidValue -> + val value = stringResource(Res.string.bandwidth_option_khz, invalidValue.toString()) + val invalidLabel = stringResource(Res.string.bandwidth_unsupported, value) + items += DropDownItem(value = invalidValue, label = invalidLabel, enabled = false) + } + + DropDownPreference( + title = stringResource(Res.string.bandwidth), + summary = + if (selection.isValid) { + null + } else { + stringResource(Res.string.bandwidth_unsupported_summary) + }, + enabled = enabled, + items = items, + selectedItem = config.bandwidth, + onItemSelected = { onConfigChange(config.copy(bandwidth = it)) }, + ) +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt index 68e891e2b4..7f4b8db38b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt @@ -56,6 +56,7 @@ fun > RadioConfigScreenList( enabled: Boolean, onSave: (T) -> Unit, modifier: Modifier = Modifier, + saveEnabled: Boolean = enabled, rebootBehavior: RebootBehavior = RebootBehavior.MAY_RESTART, actions: @Composable () -> Unit = {}, additionalDirtyCheck: () -> Boolean = { false }, @@ -96,6 +97,7 @@ fun > RadioConfigScreenList( ) { PreferenceFooter( enabled = enabled && showFooterButtons, + positiveEnabled = saveEnabled, negativeText = stringResource(Res.string.discard_changes), onNegativeClicked = { focusManager.clearFocus() @@ -110,7 +112,7 @@ fun > RadioConfigScreenList( }, onPositiveClicked = { focusManager.clearFocus() - onSave(configState.value) + if (saveEnabled) onSave(configState.value) }, ) } diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index 4688a53dc9..7dc0f232af 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -76,6 +76,7 @@ import org.meshtastic.core.testing.FakeLockdownCoordinator import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.ui.util.SnackbarManager import org.meshtastic.feature.settings.navigation.ConfigRoute +import org.meshtastic.feature.settings.radio.component.loRaBandwidthSelection import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.ChannelSettings @@ -97,6 +98,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration @@ -1268,6 +1270,65 @@ class RadioConfigViewModelTest { assertTrue(remoteVm.radioConfigState.value.responseState is ResponseState.Loading) } + @Test + fun `local destination exposes its PlatformIO target`() = runTest { + val localNode = Node(num = 100, user = User(id = "!100")) + nodeRepository.setNodes(listOf(localNode)) + nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100, pioEnv = "tlora-v2-1-1_8")) + + val localVm = createViewModel(destNum = 100) + runCurrent() + + assertTrue(localVm.radioConfigState.value.isLocal) + assertEquals("tlora-v2-1-1_8", localVm.radioConfigState.value.pioEnv) + } + + @Test + fun `local destination updates its PlatformIO target when identity is unchanged`() = runTest { + val localNode = Node(num = 100, user = User(id = "!100")) + nodeRepository.setNodes(listOf(localNode)) + nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100, pioEnv = "tlora-t3s3-v1")) + val localVm = createViewModel(destNum = 100) + runCurrent() + + val beforeReflash = + loRaBandwidthSelection( + storedValue = 800, + region = Config.LoRaConfig.RegionCode.LORA_24, + hwModel = null, + pioEnv = localVm.radioConfigState.value.pioEnv, + ) + assertFalse(beforeReflash.options.orEmpty().any { it.wireValue == 1600 }) + + nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100, pioEnv = "my-esp32s3-diy-oled")) + runCurrent() + + val afterReflash = + loRaBandwidthSelection( + storedValue = 800, + region = Config.LoRaConfig.RegionCode.LORA_24, + hwModel = null, + pioEnv = localVm.radioConfigState.value.pioEnv, + ) + assertTrue(localVm.radioConfigState.value.isLocal) + assertEquals("my-esp32s3-diy-oled", localVm.radioConfigState.value.pioEnv) + assertTrue(afterReflash.options.orEmpty().any { it.wireValue == 1600 }) + } + + @Test + fun `remote destination never inherits gateway PlatformIO target`() = runTest { + val localNode = Node(num = 100, user = User(id = "!100")) + val remoteNode = Node(num = 456, user = User(id = "!456")) + nodeRepository.setNodes(listOf(localNode, remoteNode)) + nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100, pioEnv = "tlora-v2-1-1_8")) + + val remoteVm = createViewModel(destNum = 456) + runCurrent() + + assertFalse(remoteVm.radioConfigState.value.isLocal) + assertNull(remoteVm.radioConfigState.value.pioEnv) + } + @Test fun `loraRegionPresetMapFlow populates state`() = runTest { val node = Node(num = 123, user = User(id = "!123")) @@ -1388,7 +1449,7 @@ class RadioConfigViewModelTest { ChannelSettings(name = "D"), ) - private fun myNodeInfo(myNodeNum: Int) = MyNodeInfo( + private fun myNodeInfo(myNodeNum: Int, pioEnv: String? = null) = MyNodeInfo( myNodeNum = myNodeNum, hasGPS = false, model = null, @@ -1403,6 +1464,7 @@ class RadioConfigViewModelTest { channelUtilization = 0f, airUtilTx = 0f, deviceId = null, + pioEnv = pioEnv, ) @Test diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LoRaBandwidthPolicyTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LoRaBandwidthPolicyTest.kt new file mode 100644 index 0000000000..b0ba68ef2d --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LoRaBandwidthPolicyTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import org.meshtastic.proto.Config.LoRaConfig.RegionCode +import org.meshtastic.proto.HardwareModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LoRaBandwidthPolicyTest { + + @Test + fun subGhzRegion_remainsUnconstrained() { + val selection = loRaBandwidthSelection(123, RegionCode.US, hwModel = null, pioEnv = null) + + assertNull(selection.options) + assertNull(selection.invalidPersistedValue) + assertTrue(selection.isValid) + } + + @Test + fun unsetRegion_preservesProtobufDefault() { + val selection = loRaBandwidthSelection(0, RegionCode.UNSET, hwModel = null, pioEnv = null) + + assertNull(selection.options) + assertNull(selection.invalidPersistedValue) + assertTrue(selection.isValid) + } + + @Test + fun mixedTloraT3S3_usesConservativeHighBandOptions() { + val selection = + loRaBandwidthSelection( + 400, + RegionCode.LORA_24, + hwModel = HardwareModel.TLORA_T3_S3, + pioEnv = "tlora-t3s3-v1", + ) + + assertEquals(listOf(0, 200, 400, 800), selection.options?.map { it.wireValue }) + assertTrue(selection.isValid) + } + + @Test + fun mixedHardwareModel_cannotBeUnlockedByConflictingTarget() { + val selection = + loRaBandwidthSelection( + 1600, + RegionCode.LORA_24, + hwModel = HardwareModel.TLORA_T3_S3, + pioEnv = "tlora-v2-1-1_8", + ) + + assertEquals(listOf(0, 200, 400, 800), selection.options?.map { it.wireValue }) + assertEquals(1600, selection.invalidPersistedValue) + assertFalse(selection.isValid) + } + + @Test + fun lr1121Target_excludes1600AndRetainsInvalidPersistedValue() { + val selection = + loRaBandwidthSelection(1600, RegionCode.LORA_24, hwModel = HardwareModel.MUZI_BASE, pioEnv = "muzi-base") + + assertEquals(listOf(0, 200, 400, 800), selection.options?.map { it.wireValue }) + assertEquals(1600, selection.invalidPersistedValue) + assertFalse(selection.isValid) + } + + @Test + fun lr1121HardwareModel_cannotBeUnlockedByConflictingTarget() { + val selection = + loRaBandwidthSelection( + 1600, + RegionCode.LORA_24, + hwModel = HardwareModel.MUZI_BASE, + pioEnv = "tlora-v2-1-1_8", + ) + + assertEquals(listOf(0, 200, 400, 800), selection.options?.map { it.wireValue }) + assertEquals(1600, selection.invalidPersistedValue) + assertFalse(selection.isValid) + } + + @Test + fun unknownLora24Targets_useConservativeHighBandOptions() { + listOf(null, "future-radio").forEach { target -> + val selection = loRaBandwidthSelection(800, RegionCode.LORA_24, hwModel = null, pioEnv = target) + + assertEquals(listOf(0, 200, 400, 800), selection.options?.map { it.wireValue }) + assertTrue(selection.isValid) + } + } + + @Test + fun invalidLora24SubGhzValue_mustBeReplaced() { + val selection = + loRaBandwidthSelection( + 125, + RegionCode.LORA_24, + hwModel = HardwareModel.TLORA_V2_1_1P8, + pioEnv = "tlora-v2-1-1_8", + ) + + assertEquals(125, selection.invalidPersistedValue) + assertFalse(selection.isValid) + assertFalse(selection.allowsSave(usePreset = false)) + assertTrue(selection.allowsSave(usePreset = true)) + } + + @Test + fun lora24ProtobufDefault_usesFirmwareDefaultBandwidth() { + val selection = loRaBandwidthSelection(0, RegionCode.LORA_24, hwModel = null, pioEnv = null) + + assertEquals(listOf(0, 200, 400, 800), selection.options?.map { it.wireValue }) + assertNull(selection.invalidPersistedValue) + assertTrue(selection.allowsSave(usePreset = false)) + assertTrue(selection.allowsSave(usePreset = true)) + } + + @Test + fun provenSx128xOnlyTargets_include1600() { + val targets = + listOf( + "betafpv_2400_tx_micro", + "makerpython_nrf52840_sx1280_eink", + "makerpython_nrf52840_sx1280_oled", + "my-esp32s3-diy-eink", + "my-esp32s3-diy-oled", + "tlora-v2-1-1_8", + ) + + targets.forEach { target -> + val selection = loRaBandwidthSelection(1600, RegionCode.LORA_24, hwModel = null, pioEnv = target) + + assertEquals(listOf(0, 200, 400, 800, 1600), selection.options?.map { it.wireValue }) + assertTrue(selection.isValid) + } + } + + @Test + fun canonicalCodes_haveFirmwareDisplayBandwidths() { + val selection = loRaBandwidthSelection(200, RegionCode.LORA_24, hwModel = null, pioEnv = null) + + assertEquals(listOf("812.5", "203.125", "406.25", "812.5"), selection.options?.map { it.displayKilohertz }) + } +} diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LoRaBandwidthUiTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LoRaBandwidthUiTest.kt new file mode 100644 index 0000000000..559263ab30 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/LoRaBandwidthUiTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio.component + +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.bandwidth_unsupported_summary +import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.save_changes +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.feature.settings.radio.ResponseState +import org.meshtastic.proto.Config +import org.meshtastic.proto.Config.LoRaConfig.RegionCode +import org.meshtastic.proto.HardwareModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalTestApi::class) +class LoRaBandwidthUiTest { + + @Test + fun invalidPersistedValues_explainWhyAndCannotSendUntilReplaced() = runComposeUiTest { + val initialConfig = + Config.LoRaConfig(use_preset = false, region = RegionCode.LORA_24, bandwidth = 125, hop_limit = 1) + lateinit var configState: ConfigState + var savedConfig: Config.LoRaConfig? = null + + setContent { + AppTheme { + configState = rememberConfigState(initialConfig) + val selection = + loRaBandwidthSelection( + storedValue = configState.value.bandwidth, + region = configState.value.region, + hwModel = HardwareModel.MUZI_BASE, + pioEnv = "muzi-base", + ) + RadioConfigScreenList( + title = "LoRa", + onBack = {}, + responseState = ResponseState.Empty, + onDismissPacketResponse = {}, + configState = configState, + enabled = true, + saveEnabled = selection.allowsSave(configState.value.use_preset), + onSave = { savedConfig = it }, + ) { + item { + LoRaBandwidthPreference( + config = configState.value, + selection = selection, + enabled = true, + focusManager = LocalFocusManager.current, + onConfigChange = { configState.value = it }, + ) + } + } + } + } + + runOnIdle { configState.value = configState.value.copy(hop_limit = 2) } + + onNodeWithText("Unsupported (125 kHz)").assertIsDisplayed() + onNodeWithText(getString(Res.string.bandwidth_unsupported_summary)).assertIsDisplayed() + onNodeWithText(getString(Res.string.save_changes)).assertIsNotEnabled().performClick() + runOnIdle { assertNull(savedConfig) } + + runOnIdle { configState.value = configState.value.copy(bandwidth = 0) } + onNodeWithText("Default (812.5 kHz)").assertIsDisplayed() + onNodeWithText(getString(Res.string.save_changes)).assertIsEnabled().performClick() + + runOnIdle { assertEquals(0, savedConfig?.bandwidth) } + } +}