Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
fe1f5d9
Enhance LoRa configuration with modem presets and validation logic
NomDeTom Mar 6, 2026
3ff2c3b
Rename bootstrapLoRaConfigFromPreset tests to validateModemConfig for…
NomDeTom Mar 6, 2026
9f1a824
Merge branch 'meshtastic:develop' into radio_interface_cherrypick
NomDeTom Mar 6, 2026
feb5172
additional tidy-ups to the validateModemConfig - still fundamentally …
NomDeTom Mar 7, 2026
e71e6a9
Enhance region validation by adding numPresets to RegionInfo and impl…
NomDeTom Mar 8, 2026
36a8215
Add validation for modem configuration in applyModemConfig
NomDeTom Mar 8, 2026
0254105
Fix region unset handling and improve modem config validation in hand…
NomDeTom Mar 8, 2026
5259494
Refactor LoRa configuration validation methods and introduce clamping…
NomDeTom Mar 9, 2026
cf9712e
Update handleSetConfig to use fromOthers parameter to either correct …
NomDeTom Mar 10, 2026
eb240d7
Merge branch 'develop' into radio_interface_cherrypick
NomDeTom Mar 10, 2026
237ac2c
Fix some of the copilot review comments for LoRa configuration valida…
NomDeTom Mar 10, 2026
0ada155
Redid the slot default checking and calculation. Should resolve the o…
NomDeTom Mar 11, 2026
4a5da3e
Add bandwidth calculation for LoRa modem preset fallback in clampConf…
NomDeTom Mar 11, 2026
ef72c40
Remove unused preset name variable in validateConfigLora and fix defa…
NomDeTom Mar 11, 2026
2e84995
update tests for region handling
NomDeTom Mar 11, 2026
251b64f
Merge branch 'develop' into radio_interface_cherrypick
NomDeTom Mar 11, 2026
b6d55af
Got the synthetic colleague to add mock service for testing
NomDeTom Mar 11, 2026
c73b592
Flash savings... hopefully
NomDeTom Mar 12, 2026
ca28017
Refactor modem preset handling to use sentinel values and improve def…
NomDeTom Mar 12, 2026
3c3ddde
Refactor region handling to use profile structures for modem presets …
NomDeTom Mar 12, 2026
d768507
added comments for clarity on parameters
NomDeTom Mar 12, 2026
a9bc5ce
Add shadow table tests and validateConfigLora enhancements for region…
NomDeTom Mar 14, 2026
e14a6a5
Merge branch 'meshtastic:develop' into radio_interface_cherrypick
NomDeTom Mar 14, 2026
e475562
Add isFromUs tests for preset validation in AdminModule
NomDeTom Mar 14, 2026
d554c82
Merge branch 'meshtastic:develop' into radio_interface_cherrypick
NomDeTom Mar 15, 2026
e3d5b49
Respond to copilot github review
NomDeTom Mar 18, 2026
4c28a6b
Merge branch 'develop' into radio_interface_cherrypick
NomDeTom Mar 18, 2026
b4ca217
address copilot comments
NomDeTom Mar 18, 2026
f9ea6be
address null poointers
NomDeTom Mar 18, 2026
f5e959d
fix build errors
NomDeTom Mar 18, 2026
b471666
Fix the fix, undo the silly suggestions from synthetic reviewer.
NomDeTom Mar 18, 2026
eca8218
we all float here
NomDeTom Mar 18, 2026
dbedf27
Fix include path for AdminModule in test_main.cpp
NomDeTom Mar 18, 2026
c31c904
Potential fix for pull request finding
NomDeTom Mar 19, 2026
dfc38fc
More suggestion fixes
NomDeTom Mar 19, 2026
ca74713
Merge branch 'develop' into radio_interface_cherrypick
NomDeTom Mar 19, 2026
1d9d6bb
admin module merge conflicts
NomDeTom Mar 19, 2026
78d8802
admin module fixes from merge hell
NomDeTom Mar 19, 2026
d060e0d
Merge branch 'develop' into radio_interface_cherrypick
NomDeTom Mar 19, 2026
107191b
fix: initialize default frequency slot and custom channel name; updat…
NomDeTom Mar 20, 2026
f1a56bc
save some bytes...
NomDeTom Mar 20, 2026
7ef568b
Merge branch 'develop' into radio_interface_cherrypick
NomDeTom Mar 20, 2026
2ee48b7
fix: simplify error logging for bandwidth checks in LoRa configuration
NomDeTom Mar 20, 2026
3cba534
Update src/mesh/MeshRadio.h
NomDeTom Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/mesh/MeshRadio.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,38 @@ struct RegionInfo {
meshtastic_Config_LoRaConfig_RegionCode code;
float freqStart;
float freqEnd;
float dutyCycle;
float spacing;
float dutyCycle; // modified by getEffectiveDutyCycle
float spacing; // gaps between radio channels
float padding; // padding at each side of the "operating channel"
uint8_t powerLimit; // Or zero for not set
bool audioPermitted;
bool freqSwitching;
bool wideLora;
const char *name; // EU433 etc
bool licensedOnly; // Only allow in HAM mode
int8_t textThrottle; // text broadcast throttle - signed to allow future changes
int8_t positionThrottle; // position broadcast throttle - signed to allow future changes
int8_t telemetryThrottle; // telemetry broadcast throttle - signed to allow future changes
uint8_t overrideSlot; // default frequency slot if not using channel hashing
meshtastic_Config_LoRaConfig_ModemPreset defaultPreset;
// static list of available presets
const meshtastic_Config_LoRaConfig_ModemPreset *availablePresets;
size_t numPresets; // number of presets in the availablePresets list, for validation
const char *name; // EU433 etc
};

extern const RegionInfo regions[];
extern const RegionInfo *myRegion;

extern void initRegion();

// modem presets for each region.
extern meshtastic_Config_LoRaConfig_ModemPreset PRESETS_STD[];
extern meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_868[];
// extern meshtastic_Config_LoRaConfig_ModemPreset PRESETS_LITE[];
// extern meshtastic_Config_LoRaConfig_ModemPreset PRESETS_NARROW[];
// extern meshtastic_Config_LoRaConfig_ModemPreset PRESETS_HAM[];
extern meshtastic_Config_LoRaConfig_ModemPreset PRESETS_UNDEF[];

// Valid LoRa spread factor range and defaults
constexpr uint8_t LORA_SF_MIN = 7;
constexpr uint8_t LORA_SF_MAX = 12;
Expand Down
2 changes: 1 addition & 1 deletion src/mesh/NodeDB.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1299,7 +1299,7 @@ void NodeDB::loadFromDisk()
// Coerce LoRa config fields derived from presets while bootstrapping.
// Some clients/UI components display bandwidth/spread_factor directly from config even in preset mode.
if (config.has_lora && config.lora.use_preset) {
RadioInterface::bootstrapLoRaConfigFromPreset(config.lora);
RadioInterface::clampConfigLora(config.lora);
}

if (backupSecurity.private_key.size > 0) {
Expand Down
415 changes: 302 additions & 113 deletions src/mesh/RadioInterface.cpp

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion src/mesh/RadioInterface.h
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ class RadioInterface
* Coerce LoRa config fields (bandwidth/spread_factor) derived from presets.
* This is used during early bootstrapping so UIs that display these fields directly remain consistent.
*/
static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig);
// static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig); // maybe superseded?
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving a commented-out public API declaration (with a speculative note) in a header makes the interface ambiguous and harder to maintain. Either remove it entirely, or keep it as a real declaration marked deprecated (and implement it as a thin wrapper if still needed), with a clear comment pointing to the replacement API.

Copilot uses AI. Check for mistakes.

/**
* Return true if we think the board can go to sleep (i.e. our tx queue is empty, we are not sending or receiving)
Expand Down Expand Up @@ -234,6 +234,15 @@ class RadioInterface
// Whether we use the default frequency slot given our LoRa config (region and modem preset)
static bool uses_default_frequency_slot;

// Check if a candidate region is compatible and valid.
static bool validateConfigRegion(meshtastic_Config_LoRaConfig &loraConfig);

// Check if a candidate radio configuration is valid.
static bool validateConfigLora(meshtastic_Config_LoRaConfig &loraConfig);

// Make a candidate radio configuration valid, even if it isn't.
static void clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig);

protected:
int8_t power = 17; // Set by applyModemConfig()

Expand Down
142 changes: 82 additions & 60 deletions src/modules/AdminModule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

#include "Default.h"
#include "MeshRadio.h"
#include "RadioInterface.h"
#include "TypeConversions.h"

#if !MESHTASTIC_EXCLUDE_MQTT
Expand Down Expand Up @@ -199,7 +200,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta

case meshtastic_AdminMessage_set_config_tag:
LOG_DEBUG("Client set config");
handleSetConfig(r->set_config);
handleSetConfig(r->set_config, fromOthers);
break;

case meshtastic_AdminMessage_set_module_config_tag:
Expand Down Expand Up @@ -626,7 +627,7 @@ void AdminModule::handleSetOwner(const meshtastic_User &o)
}
}

void AdminModule::handleSetConfig(const meshtastic_Config &c)
void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
{
auto changes = SEGMENT_CONFIG;
auto existingRole = config.device.role;
Expand Down Expand Up @@ -768,6 +769,83 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c)
validatedLora.spread_factor = LORA_SF_DEFAULT;
}

#if HAS_LORA_FEM
// Apply FEM LNA mode from config (only meaningful on hardware that supports it)
if (loraFEMInterface.isLnaCanControl()) {
loraFEMInterface.setLNAEnable(validatedLora.fem_lna_mode == meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED);
} else if (validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) {
// Hardware FEM does not support LNA control; normalize stored config to match actual capability
LOG_WARN("FEM LNA mode configured but current FEM does not support LNA control; normalizing to NOT_PRESENT");
validatedLora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT;
}
#endif
// If we're setting a new region, check the region is valid and then init the region or discard the change
if (validatedLora.region != myRegion->code) {
// Region has changed so check whether there is a regulatory one we should be using instead.
// Additionally as a side-effect, assume a new value under myRegion
if (RadioInterface::validateConfigRegion(config.lora)) {

// If we're setting region for the first time, init the region and regenerate the keys
if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (!owner.is_licensed) {
bool keygenSuccess = false;
if (config.security.private_key.size == 32) {
if (crypto->regeneratePublicKey(config.security.public_key.bytes,
config.security.private_key.bytes)) {
keygenSuccess = true;
}
} else {
LOG_INFO("Generate new PKI keys");
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
keygenSuccess = true;
}
if (keygenSuccess) {
config.security.public_key.size = 32;
config.security.private_key.size = 32;
owner.public_key.size = 32;
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
}
}
#endif
validatedLora.tx_enabled = true;
}
// If we're unsetting the region for some reason, disable tx
if (!isRegionUnset && validatedLora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
validatedLora.tx_enabled = false;
}
if (!RadioInterface::validateConfigLora(validatedLora)) {
// use_preset and bandwidth are coerced into valid values by the check.
// modem_preset set to a valid setting based on the check result here
validatedLora.modem_preset = myRegion->defaultPreset;
}
initRegion();
if (myRegion->dutyCycle < 100) {
validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
}
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
// Default root is in use, so subscribe to the appropriate MQTT topic for this region
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
}
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
} else {
// Region validation has failed, so just copy all of the old config over the new config
validatedLora = oldLoraConfig;
}
} // end of new region handling

if (!RadioInterface::validateConfigLora(validatedLora)) {
if (fromOthers) {
LOG_WARN("Invalid LoRa config received from another node, rejecting changes");
// modem_preset set to use the old setting if the check fails
validatedLora.modem_preset = oldLoraConfig.modem_preset;
} else {
LOG_WARN("Invalid LoRa config received from client, using corrected values");
RadioInterface::clampConfigLora(validatedLora);
}
// use_preset and bandwidth are coerced into valid values by the check.
}

// If no lora radio parameters change, don't need to reboot
if (oldLoraConfig.use_preset == validatedLora.use_preset && oldLoraConfig.region == validatedLora.region &&
oldLoraConfig.modem_preset == validatedLora.modem_preset && oldLoraConfig.bandwidth == validatedLora.bandwidth &&
Expand Down Expand Up @@ -796,62 +874,6 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c)
}
#endif
config.lora = validatedLora;

#if HAS_LORA_FEM
// Apply FEM LNA mode from config (only meaningful on hardware that supports it)
if (loraFEMInterface.isLnaCanControl()) {
loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode == meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED);
} else if (config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) {
// Hardware FEM does not support LNA control; normalize stored config to match actual capability
LOG_WARN("FEM LNA mode configured but current FEM does not support LNA control; normalizing to NOT_PRESENT");
config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT;
}
#endif
// If we're setting region for the first time, init the region and regenerate the keys
if (isRegionUnset && config.lora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
if (!owner.is_licensed) {
bool keygenSuccess = false;
if (config.security.private_key.size == 32) {
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
keygenSuccess = true;
}
} else {
LOG_INFO("Generate new PKI keys");
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
keygenSuccess = true;
}
if (keygenSuccess) {
config.security.public_key.size = 32;
config.security.private_key.size = 32;
owner.public_key.size = 32;
memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);
}
}
#endif
config.lora.tx_enabled = true;
initRegion();
if (myRegion->dutyCycle < 100) {
config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit
}
// Compare the entire string, we are sure of the length as a topic has never been set
if (strcmp(moduleConfig.mqtt.root, default_mqtt_root) == 0) {
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
}
}
if (config.lora.region != myRegion->code) {
// Region has changed so check whether there is a regulatory one we should be using instead.
// Additionally as a side-effect, assume a new value under myRegion
initRegion();

if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
// Default root is in use, so subscribe to the appropriate MQTT topic for this region
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name);
}

changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
}
break;
}
case meshtastic_Config_bluetooth_tag:
Expand Down Expand Up @@ -899,10 +921,10 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c)
}
if (requiresReboot && !hasOpenEditTransaction) {
disableBluetooth();
}
} // end of switch case which_payload_variant

saveChanges(changes, requiresReboot);
}
} // end of handleSetConfig

bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
{
Expand Down
2 changes: 1 addition & 1 deletion src/modules/AdminModule.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
*/
void handleSetOwner(const meshtastic_User &o);
void handleSetChannel(const meshtastic_Channel &cc);
void handleSetConfig(const meshtastic_Config &c);
void handleSetConfig(const meshtastic_Config &c, bool fromOthers);
bool handleSetModuleConfig(const meshtastic_ModuleConfig &c);
void handleSetChannel();
void handleSetHamMode(const meshtastic_HamParameters &req);
Expand Down
24 changes: 12 additions & 12 deletions test/test_radio/test_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ static void test_bwCodeToKHz_passthrough()
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 250.0f, bwCodeToKHz(250));
}

static void test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse()
static void test_validateModemConfig_noopWhenUsePresetFalse()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = false;
Expand All @@ -30,47 +30,47 @@ static void test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse()
cfg.bandwidth = 123;
cfg.spread_factor = 8;

RadioInterface::bootstrapLoRaConfigFromPreset(cfg);
RadioInterface::validateModemConfig(cfg);

TEST_ASSERT_EQUAL_UINT16(123, cfg.bandwidth);
TEST_ASSERT_EQUAL_UINT32(8, cfg.spread_factor);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset);
}

static void test_bootstrapLoRaConfigFromPreset_setsDerivedFields_nonWideRegion()
static void test_validateModemConfig_setsDerivedFields_nonWideRegion()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;

RadioInterface::bootstrapLoRaConfigFromPreset(cfg);
RadioInterface::validateModemConfig(cfg);

TEST_ASSERT_EQUAL_UINT16(250, cfg.bandwidth);
TEST_ASSERT_EQUAL_UINT32(9, cfg.spread_factor);
}

static void test_bootstrapLoRaConfigFromPreset_setsDerivedFields_wideRegion()
static void test_validateModemConfig_setsDerivedFields_wideRegion()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;

RadioInterface::bootstrapLoRaConfigFromPreset(cfg);
RadioInterface::validateModemConfig(cfg);

TEST_ASSERT_EQUAL_UINT16(800, cfg.bandwidth);
TEST_ASSERT_EQUAL_UINT32(9, cfg.spread_factor);
}

static void test_bootstrapLoRaConfigFromPreset_fallsBackIfBandwidthExceedsRegionSpan()
static void test_validateModemConfig_fallsBackIfBandwidthExceedsRegionSpan()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.use_preset = true;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO;

RadioInterface::bootstrapLoRaConfigFromPreset(cfg);
RadioInterface::validateModemConfig(cfg);

TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
TEST_ASSERT_EQUAL_UINT16(250, cfg.bandwidth);
Expand All @@ -90,10 +90,10 @@ void setup()
UNITY_BEGIN();
RUN_TEST(test_bwCodeToKHz_specialMappings);
RUN_TEST(test_bwCodeToKHz_passthrough);
RUN_TEST(test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse);
RUN_TEST(test_bootstrapLoRaConfigFromPreset_setsDerivedFields_nonWideRegion);
RUN_TEST(test_bootstrapLoRaConfigFromPreset_setsDerivedFields_wideRegion);
RUN_TEST(test_bootstrapLoRaConfigFromPreset_fallsBackIfBandwidthExceedsRegionSpan);
RUN_TEST(test_validateModemConfig_noopWhenUsePresetFalse);
RUN_TEST(test_validateModemConfig_setsDerivedFields_nonWideRegion);
RUN_TEST(test_validateModemConfig_setsDerivedFields_wideRegion);
RUN_TEST(test_validateModemConfig_fallsBackIfBandwidthExceedsRegionSpan);
exit(UNITY_END());
}

Expand Down
Loading