From bc035bb8124975ce916e38f1677ec8ef9edec040 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:48:14 +0000 Subject: [PATCH] feat(lora): state a pinned userPrefs preset as the unset region's intent (#11507) * feat(lora): state a pinned userPrefs preset as the unset region's intent A vendor build can pin USERPREFS_LORACONFIG_MODEM_PRESET while leaving the region unset, so a fresh flash comes up as region UNSET plus a deliberate preset. Stock installs come up as region UNSET plus the LONG_FAST placeholder, and nothing in FromRadio told the two apart - so clients treat every unset-region node as factory-fresh and replace its preset with the region default as soon as the user picks a region. A mesh pinned to SHORT_TURBO loses every new node to LONG_FAST or LONG_TURBO, silently. getRegionPresetMap() now emits an UNSET entry when, and only when, the build pins a preset, stating that preset as both the group's sole entry and its default. Stock builds are unchanged on the wire: no UNSET entry, which clients already read as unconstrained. This is intent, not enforcement. supportsPreset() still accepts any known preset while the region is unset (#11496) and the radio is held silent either way, so the device continues to honour whatever the user or an admin sets. Costs one group slot and one region slot on pinned builds only (6->7 of 8, 34->35 of 38); exhaustion is logged and degrades to the existing unconstrained behaviour. * Trim comments to the project's one-to-two-line limit --- src/mesh/RadioInterface.cpp | 20 +++++++++++++++++ test/test_radio/test_main.cpp | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 58bd498c5..5db23ab54 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -744,6 +744,26 @@ void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map) rg.region = r->code; rg.group_index = (uint8_t)gi; } + +#ifdef USERPREFS_LORACONFIG_MODEM_PRESET + // A pinned preset is a statement of intent, not enforcement: supportsPreset() still accepts any + // known preset while unset. Stock builds emit no UNSET entry, which clients read as unconstrained. + if (map.groups_count < maxGroups && map.region_groups_count < maxRegions) { + const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET); + meshtastic_LoRaPresetGroup &grp = map.groups[map.groups_count]; + grp.presets_count = 1; + grp.presets[0] = USERPREFS_LORACONFIG_MODEM_PRESET; + grp.default_preset = USERPREFS_LORACONFIG_MODEM_PRESET; + grp.licensed_only = unset->profile->licensedOnly; + + meshtastic_LoRaRegionPresets &rg = map.region_groups[map.region_groups_count++]; + rg.region = unset->code; + rg.group_index = (uint8_t)map.groups_count++; + } else { + // Costs only the intent signal - clients fall back to unconstrained - but must not be silent. + LOG_ERROR("Region preset map full; UNSET intent omitted"); + } +#endif } /** diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index 4fb75631e..87f3f3724 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -298,7 +298,11 @@ static void test_regionPresetMap_coversAllRegionsWithinBounds() meshtastic_LoRaRegionPresetMap map; getRegionPresetMap(map); +#ifdef USERPREFS_LORACONFIG_MODEM_PRESET + const size_t known = countKnownRegions() + 1; // + the UNSET intent entry +#else const size_t known = countKnownRegions(); +#endif TEST_ASSERT_EQUAL_UINT((unsigned)known, (unsigned)map.region_groups_count); // Bounds derived from the generated nanopb arrays (mesh.options max_count), so @@ -334,6 +338,12 @@ static void test_regionPresetMap_matchesRegionTable() const meshtastic_LoRaPresetGroup &grp = map.groups[gi]; const RegionInfo *r = getRegion(code); +#ifdef USERPREFS_LORACONFIG_MODEM_PRESET + // UNSET states the pinned preset, not PROFILE_UNDEF's list, so the table checks below don't apply. + if (code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + continue; +#endif + // Group's list is non-empty and within the generated array bound. const size_t maxPresets = sizeof(grp.presets) / sizeof(grp.presets[0]); TEST_ASSERT_GREATER_THAN_UINT(0, grp.presets_count); @@ -373,6 +383,36 @@ static void test_regionPresetMap_matchesRegionTable() } } +// UNSET appears only when the build pins a preset, and then states exactly that preset. +// A stock build leaves it out entirely, which clients read as "unconstrained". +static void test_regionPresetMap_unsetCarriesUserprefsIntent() +{ + meshtastic_LoRaRegionPresetMap map; + getRegionPresetMap(map); + + const meshtastic_LoRaPresetGroup *grp = nullptr; + for (pb_size_t i = 0; i < map.region_groups_count; i++) + if (map.region_groups[i].region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + grp = &map.groups[map.region_groups[i].group_index]; + +#ifdef USERPREFS_LORACONFIG_MODEM_PRESET + const meshtastic_Config_LoRaConfig_ModemPreset pinned = USERPREFS_LORACONFIG_MODEM_PRESET; + TEST_ASSERT_NOT_NULL_MESSAGE(grp, "a build that pins a preset must state it for UNSET"); + TEST_ASSERT_EQUAL_UINT_MESSAGE(1, (unsigned)grp->presets_count, "the pinned preset is the sole entry"); + TEST_ASSERT_EQUAL(pinned, grp->presets[0]); + TEST_ASSERT_EQUAL(pinned, grp->default_preset); + TEST_ASSERT_FALSE_MESSAGE(grp->licensed_only, "UNSET is not a licensed-only region"); + + // Stating intent must not narrow what the device accepts: the firmware still takes any + // real preset while the region is unset (#11496), so the map cannot become enforcement. + const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET); + TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST)); + TEST_ASSERT_TRUE(unset->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO)); +#else + TEST_ASSERT_NULL_MESSAGE(grp, "a stock build must leave UNSET out of the map entirely"); +#endif +} + void setUp(void) { mockMeshService = new MockMeshService(); @@ -422,6 +462,7 @@ void setup() RUN_TEST(test_clampConfigLora_mediumTurboValidForUS); RUN_TEST(test_regionPresetMap_coversAllRegionsWithinBounds); RUN_TEST(test_regionPresetMap_matchesRegionTable); + RUN_TEST(test_regionPresetMap_unsetCarriesUserprefsIntent); exit(UNITY_END()); }