From d81a91a9da2de5f84a7b99a05233f434cc54a3dd Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:26:58 -0500 Subject: [PATCH] =?UTF-8?q?feat(lora):=20gate=20region=E2=86=92preset=20ma?= =?UTF-8?q?p=20+=20TINY=20presets=20on=20firmware=20capability=20(#5941)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 --- .../org/meshtastic/core/model/Capabilities.kt | 6 ++ .../radio/component/LoRaConfigItemList.kt | 64 ++++++++++++------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt index 2aca882be..874e38b99 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt @@ -68,6 +68,12 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl /** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */ val supportsEsp32Ota = atLeast(V2_7_18) + /** + * Support for the LoRa region→preset compatibility map and TINY presets. Supported since firmware v2.8.0. Older + * firmware never sends the map, so the UI keeps the preset list unconstrained and hides the new presets. + */ + val supportsLoraRegionPresetMap = atLeast(V2_8_0) + /** * Support for runtime lockdown mode (per-connection passphrase auth). Supported since firmware v2.8.0. Note: * lockdown is also hardware-gated (nRF52 only) — the device advertises real support by sending a `LockdownStatus`, 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 95d11bc39..55fd505ee 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 @@ -27,9 +27,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalFocusManager import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.Capabilities import org.meshtastic.core.model.Channel import org.meshtastic.core.model.ChannelOption import org.meshtastic.core.model.RegionInfo +import org.meshtastic.core.model.RegionPresetConstraint import org.meshtastic.core.model.constraintFor import org.meshtastic.core.model.numChannels import org.meshtastic.core.model.repairPresetFor @@ -67,10 +69,39 @@ import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.util.hopLimits import org.meshtastic.proto.Config +import org.meshtastic.proto.Config.LoRaConfig.ModemPreset private val SPREAD_FACTOR_RANGE = 7..12 private val CODING_RATE_RANGE = 5..8 +/** + * Builds the modem-preset dropdown items: hide the 2.8-only TINY presets on firmware without + * [Capabilities.supportsLoraRegionPresetMap], restrict to the region's legal presets (R7), then always keep the current + * selection present (disabled) so the field is never blank when the device's preset is illegal for the region. + */ +private fun buildPresetItems( + presetConstraint: RegionPresetConstraint?, + presetsGated: Boolean, + selectedPreset: ModemPreset, + capabilities: Capabilities, +): List> { + val items = + ChannelOption.entries + .filter { option -> + capabilities.supportsLoraRegionPresetMap || + (option != ChannelOption.TINY_FAST && option != ChannelOption.TINY_SLOW) + } + .filter { option -> presetConstraint == null || option.modemPreset in presetConstraint.presets } + .map { option -> + DropDownItem(value = option.modemPreset, label = option.modemPreset.name, enabled = !presetsGated) + } + return if (items.any { it.value == selectedPreset }) { + items + } else { + items + DropDownItem(value = selectedPreset, label = selectedPreset.name, enabled = false) + } +} + @Composable fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() @@ -109,9 +140,13 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { ) { item { TitledCard(title = stringResource(Res.string.options)) { - // Region→preset legality map only constrains the locally connected device (R10); for remote admin - // there is no map for the remote node, so leave the preset list unconstrained. - val regionPresetMap = if (state.isLocal) state.loraRegionPresetMap else null + // The region→preset legality map is a function of firmware version + region, not of the device + // instance, so the locally-cached map (from our own handshake) is reused for remote admin too. Gated + // on the *target* node's firmware capability (metadata is per-target): pre-2.8 nodes don't get the + // map or the new TINY presets, which also keeps older remotes unconstrained. + val capabilities = + remember(state.metadata?.firmware_version) { Capabilities(state.metadata?.firmware_version) } + val regionPresetMap = if (capabilities.supportsLoraRegionPresetMap) state.loraRegionPresetMap else null val presetConstraint = remember(regionPresetMap, formState.value.region) { regionPresetMap.constraintFor(formState.value.region) @@ -145,27 +180,8 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { // device is flagged as a licensed operator (R8). val selectedPreset = formState.value.modem_preset val presetItems = - remember(presetConstraint, presetsGated, selectedPreset) { - val items = - ChannelOption.entries - .filter { option -> - presetConstraint == null || option.modemPreset in presetConstraint.presets - } - .map { option -> - DropDownItem( - value = option.modemPreset, - label = option.modemPreset.name, - enabled = !presetsGated, - ) - } - // Always keep the current selection present (disabled) so the field is never blank when - // the device's preset is illegal for the selected region. - if (items.any { it.value == selectedPreset }) { - items - } else { - items + - DropDownItem(value = selectedPreset, label = selectedPreset.name, enabled = false) - } + remember(presetConstraint, presetsGated, selectedPreset, capabilities) { + buildPresetItems(presetConstraint, presetsGated, selectedPreset, capabilities) } DropDownPreference( title = stringResource(Res.string.modem_preset),