mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-18 09:05:16 -04:00
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:
11 files changed
+506
-12
No files matched your search
Generated
+5
@@ -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 & 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))
|
||||
|
||||
@@ -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 | — |
|
||||
|
||||
+12
-3
@@ -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(),
|
||||
|
||||
+81
@@ -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)
|
||||
}
|
||||
+75
-5
@@ -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)) },
|
||||
)
|
||||
}
|
||||
+3
-1
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+63
-1
@@ -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
|
||||
|
||||
+163
@@ -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 })
|
||||
}
|
||||
}
|
||||
+96
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user