fix(model): align the US first-setup preset with the device screen (#7244)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-19 18:49:11 +00:00
1 parent 5f242f6505
commit 1b1d8845b0
5 files changed
+119 -16

No files matched your search

@@ -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),
@@ -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 }
}
@@ -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.
@@ -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),
)
}
}
@@ -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()