From 1b1d8845b0b4f8b4e34cb3c9e4e5ecd2a372afdd Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:49:11 +0000 Subject: [PATCH] fix(model): align the US first-setup preset with the device screen (#7244) --- .../meshtastic/core/model/ChannelOption.kt | 5 +- .../core/model/RegionPresetConstraint.kt | 29 +++++-- .../meshtastic/core/model/CapabilitiesTest.kt | 5 +- .../core/model/LoRaRegionPresetsTest.kt | 86 ++++++++++++++++++- .../settings/radio/channel/ChannelScreen.kt | 10 ++- 5 files changed, 119 insertions(+), 16 deletions(-) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt index ae2df4ecad..a99cdbbb14 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt @@ -115,6 +115,9 @@ internal fun LoRaConfig.radioFreq(channelNum: Int): Float { */ private val FIRMWARE_2_8 = DeviceVersion("2.8.0") +/** The firmware release that introduced the LONG_TURBO preset (firmware#8985). */ +private val FIRMWARE_2_7_17 = DeviceVersion("2.7.17") + /** * Regulatory regions for radio usage * @@ -470,7 +473,7 @@ enum class ChannelOption( // Historical parameters for firmware predating the removal of VERY_LONG_SLOW. VERY_LONG_SLOW(ModemPreset.VERY_LONG_SLOW, 0.0625f, spreadingFactor = 12), - LONG_TURBO(ModemPreset.LONG_TURBO, 0.500f, spreadingFactor = 11), + LONG_TURBO(ModemPreset.LONG_TURBO, 0.500f, spreadingFactor = 11, minFirmware = FIRMWARE_2_7_17), LONG_FAST(ModemPreset.LONG_FAST, 0.250f, spreadingFactor = 11), LONG_MODERATE(ModemPreset.LONG_MODERATE, 0.125f, spreadingFactor = 11), LONG_SLOW(ModemPreset.LONG_SLOW, 0.125f, spreadingFactor = 12), diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RegionPresetConstraint.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RegionPresetConstraint.kt index b5b00023cc..a3194ec6b9 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RegionPresetConstraint.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RegionPresetConstraint.kt @@ -79,9 +79,10 @@ fun LoRaRegionPresetMap?.repairPresetFor(region: RegionCode, current: ModemPrese * [current] selected. * * Keeps [current] (legality-repaired via [repairPresetFor]) except at fresh setup: when [previousRegion] is UNSET and - * [current] is the [ModemPreset.LONG_FAST] placeholder a factory-flashed node reports (proto default), the region's - * advertised default is adopted instead (the firmware map's when present, else the app's built-in default). Any other - * preset at UNSET was set deliberately (e.g. a vendor build pinning USERPREFS_LORACONFIG_MODEM_PRESET) and is kept. + * [current] is the [ModemPreset.LONG_FAST] placeholder a factory-flashed node reports (proto default), a region default + * is adopted instead: the app's [firstSetupDefaultFor] where the firmware map confirms it, else the map's own default. + * Any other preset at UNSET was set deliberately (e.g. a vendor build pinning USERPREFS_LORACONFIG_MODEM_PRESET) and is + * kept. * * A build that pins a preset can also advertise it as an UNSET map entry (firmware #11507), stating outright that the * preset is deliberate; that keeps even a pinned LONG_FAST, which the placeholder heuristic alone cannot distinguish. @@ -95,8 +96,8 @@ fun LoRaRegionPresetMap?.presetForRegionChange( val pinned = constraintFor(RegionCode.UNSET) != null val freshSetup = previousRegion == RegionCode.UNSET && !pinned && current == ModemPreset.LONG_FAST return if (freshSetup) { - // Re-repair the adopted default: a malformed map's advertised default may not be in its own legal set. - val preferred = constraintFor(newRegion)?.defaultPreset ?: defaultPresetFor(newRegion) ?: repaired + // Re-repaired: a malformed map's advertised default may not be in its own legal set. + val preferred = firstSetupDefaultFor(newRegion) ?: constraintFor(newRegion)?.defaultPreset ?: repaired repairPresetFor(newRegion, preferred) } else { repaired @@ -104,9 +105,9 @@ fun LoRaRegionPresetMap?.presetForRegionChange( } /** - * The app's built-in default modem presets for regions whose default differs from the global [ChannelOption.DEFAULT]. - * Mirrors the per-region defaults newer firmware advertises in [LoRaRegionPresetMap], so the default channel and a - * fresh setup over old firmware (which sends no map) land on the same preset a new node would. + * The app's first-setup default modem presets, for regions whose default differs from the global + * [ChannelOption.DEFAULT]. Firmware's [LoRaRegionPresetMap] advertises only its region-table default, which for US is + * not what its own on-device chooser installs on a first-ever setup, so these override the advertised default. */ private val REGION_DEFAULT_PRESETS = mapOf(RegionCode.US to ModemPreset.LONG_TURBO) @@ -115,3 +116,15 @@ private val REGION_DEFAULT_PRESETS = mapOf(RegionCode.US to ModemPreset.LONG_TUR * should fall back to [ChannelOption.DEFAULT] / the current preset. */ fun defaultPresetFor(region: RegionCode): ModemPreset? = REGION_DEFAULT_PRESETS[region] + +/** + * The preset a first-ever setup in [region] should install, or `null` to leave the choice alone. + * + * Gated on the firmware map: an unconstrained node is running firmware whose own region chooser installs the + * region-table default, and overriding it there would make the same node set up from the phone and from its screen + * disagree. Only a preset the map lists as legal is adopted. + */ +fun LoRaRegionPresetMap?.firstSetupDefaultFor(region: RegionCode): ModemPreset? { + val constraint = constraintFor(region) ?: return null + return defaultPresetFor(region)?.takeIf { it in constraint.presets } +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt index 6d4848316f..b57dd8edeb 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt @@ -138,7 +138,8 @@ class CapabilitiesTest { ChannelOption.NARROW_FAST, ChannelOption.NARROW_SLOW, ) - assertEquals(gated, ChannelOption.entries.filter { it.minFirmware != null }.toSet()) + // LongTurbo's radio support lands earlier, in v2.7.17 (firmware#8985), so it gates on its own version. + assertEquals(gated + ChannelOption.LONG_TURBO, ChannelOption.entries.filter { it.minFirmware != null }.toSet()) val old = caps("2.7.26") val new = caps("2.8.0") @@ -146,6 +147,8 @@ class CapabilitiesTest { assertFalse(old.supportsPreset(preset), "${preset.name} should be hidden on 2.7 firmware") assertTrue(new.supportsPreset(preset), "${preset.name} should be shown on 2.8 firmware") } + assertFalse(caps("2.7.16").supportsPreset(ChannelOption.LONG_TURBO), "LongTurbo predates 2.7.17") + assertTrue(old.supportsPreset(ChannelOption.LONG_TURBO), "LongTurbo ships from 2.7.17") // Established presets are never gated, even with unknown firmware. assertTrue(caps(null).supportsPreset(ChannelOption.LONG_FAST)) // Unknown firmware hides gated presets; debug forceEnableAll shows them. diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/LoRaRegionPresetsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/LoRaRegionPresetsTest.kt index 4f7d88d2d2..d7f1f8987c 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/LoRaRegionPresetsTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/LoRaRegionPresetsTest.kt @@ -255,14 +255,26 @@ class LoRaRegionPresetsTest { } @Test - fun `fresh setup placeholder adopts the built-in default when unconstrained`() { + fun `fresh setup keeps LongFast when firmware advertises no map`() { + // Pre-2.8 firmware installs its own region-table default on its screen; the app must not diverge from it. val nullMap: LoRaRegionPresetMap? = null assertEquals( - ModemPreset.LONG_TURBO, + ModemPreset.LONG_FAST, nullMap.presetForRegionChange(RegionCode.UNSET, RegionCode.US, ModemPreset.LONG_FAST), ) } + @Test + fun `no first-setup default is offered without a map`() { + val nullMap: LoRaRegionPresetMap? = null + assertNull(nullMap.firstSetupDefaultFor(RegionCode.US)) + } + + @Test + fun `no first-setup default when the map omits it from the region`() { + assertNull(map.firstSetupDefaultFor(RegionCode.US)) + } + @Test fun `fresh setup pin that is illegal in the region is still repaired`() { assertEquals( @@ -320,4 +332,74 @@ class LoRaRegionPresetsTest { pinnedMap.presetForRegionChange(RegionCode.UNSET, RegionCode.UA_433, ModemPreset.LONG_FAST), ) } + + // Mirrors what firmware 2.8 advertises for US: its whole legal preset list, defaulting to the region-table + // LongFast rather than the LongTurbo its own first-setup chooser installs. + private val firmware28Map = + LoRaRegionPresetMap.Builder() + .also { wb -> + wb.groups = + listOf( + LoRaPresetGroup.Builder() + .also { g -> + g.presets = + listOf( + ModemPreset.LONG_FAST, + ModemPreset.LONG_SLOW, + ModemPreset.MEDIUM_SLOW, + ModemPreset.MEDIUM_FAST, + ModemPreset.SHORT_SLOW, + ModemPreset.SHORT_FAST, + ModemPreset.LONG_MODERATE, + ModemPreset.SHORT_TURBO, + ModemPreset.LONG_TURBO, + ModemPreset.MEDIUM_TURBO, + ) + g.default_preset = ModemPreset.LONG_FAST + g.licensed_only = false + } + .build(), + ) + wb.region_groups = + listOf( + LoRaRegionPresets.Builder() + .also { r -> + r.region = RegionCode.US + r.group_index = 0 + } + .build(), + ) + } + .build() + + @Test + fun `first-setup default is offered when the map lists it as legal`() { + assertEquals(ModemPreset.LONG_TURBO, firmware28Map.firstSetupDefaultFor(RegionCode.US)) + } + + @Test + fun `fresh US setup adopts LongTurbo over the advertised LongFast default`() { + assertEquals( + ModemPreset.LONG_TURBO, + firmware28Map.presetForRegionChange(RegionCode.UNSET, RegionCode.US, ModemPreset.LONG_FAST), + ) + } + + @Test + fun `a later US change on the advertised map keeps the running preset`() { + // Default adoption stays a fresh-setup rule: an already-configured node is never moved off its preset. + assertEquals( + ModemPreset.LONG_FAST, + firmware28Map.presetForRegionChange(RegionCode.EU_868, RegionCode.US, ModemPreset.LONG_FAST), + ) + } + + @Test + fun `fresh setup falls back to the advertised default when the built-in is illegal there`() { + // The US group here omits LONG_TURBO, so the built-in cannot be adopted. + assertEquals( + ModemPreset.LONG_FAST, + map.presetForRegionChange(RegionCode.UNSET, RegionCode.US, ModemPreset.LONG_FAST), + ) + } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt index 98cb45204d..7a0da443fc 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt @@ -63,7 +63,7 @@ import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.model.Channel import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.defaultPresetFor +import org.meshtastic.core.model.firstSetupDefaultFor import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.core.resources.Res @@ -217,9 +217,11 @@ fun ChannelScreen( messageRes = Res.string.are_you_sure_change_default, onConfirm = { Logger.d { "Switching back to default channel" } - // The default channel takes the region's preferred preset (e.g. US -> LongTurbo) so its derived name, - // hash, and frequency match what a freshly-set-up node in that region would use. - val preset = defaultPresetFor(viewModel.region) ?: Channel.default.loraConfig.modem_preset + // The default channel takes the region's first-setup preset (e.g. US -> LongTurbo) so its derived + // name, hash, and frequency match what a freshly-set-up node in that region would use. + val preset = + radioConfigState.loraRegionPresetMap.firstSetupDefaultFor(viewModel.region) + ?: Channel.default.loraConfig.modem_preset val lora = Channel.default.loraConfig .newBuilder()