fix(lora): validate 2.4 GHz bandwidth options (#6529)

Co-authored-by: James Rich <james.a.rich@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-08-13 16:15:08 +00:00
1 parent e330442dd5
commit cf0f7a42a7
11 files changed
+506 -12

No files matched your search

+5
View File
@@ -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
@@ -132,7 +132,12 @@
<string name="backup_keys_confirmation">Saves the public and private keys to secure, encrypted storage on this device.</string>
<string name="backup_restore">Backup &amp; Restore</string>
<string name="bad">Bad</string>
<!-- BANDWIDTH -->
<string name="bandwidth">Bandwidth</string>
<string name="bandwidth_default">Default (%1$s kHz)</string>
<string name="bandwidth_option_khz">%1$s kHz</string>
<string name="bandwidth_unsupported">Unsupported (%1$s)</string>
<string name="bandwidth_unsupported_summary">This bandwidth is not supported by the connected radio in the selected region. Choose a supported value before saving.</string>
<string name="baro_pressure">Baro</string>
<string name="battery">Battery</string>
<string name="battery_ina_2xx_i2c_address">Battery INA_2XX I2C address</string>
@@ -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))
+1 -1
View File
@@ -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: 07 | — |
@@ -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(),
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<LoRaBandwidthOption>?,
/** 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)
}
@@ -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)) },
)
}
@@ -56,6 +56,7 @@ fun <T : Message<T, *>> 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 <T : Message<T, *>> RadioConfigScreenList(
) {
PreferenceFooter(
enabled = enabled && showFooterButtons,
positiveEnabled = saveEnabled,
negativeText = stringResource(Res.string.discard_changes),
onNegativeClicked = {
focusManager.clearFocus()
@@ -110,7 +112,7 @@ fun <T : Message<T, *>> RadioConfigScreenList(
},
onPositiveClicked = {
focusManager.clearFocus()
onSave(configState.value)
if (saveEnabled) onSave(configState.value)
},
)
}
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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 })
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Config.LoRaConfig>
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) }
}
}