EU regions merge (#10675)

* stronger together

* validate 2.4ghz regions

* less noise

* you're right, and that shapens the analysis significantly

* sassy rejoinder
This commit is contained in:
Tom
2026-06-10 18:37:14 +01:00
committed by GitHub
co-authored by GitHub
parent 2541db2bef
commit ab882c5619
9 changed files with 389 additions and 60 deletions
+24 -8
View File
@@ -180,6 +180,18 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool
config.lora.region = region; config.lora.region = region;
config.lora.channel_num = 0; // Reset to default channel config.lora.channel_num = 0; // Reset to default channel
// Reconcile the preset with the explicitly chosen region: a preset locked to another
// region would leave config.lora invalid until applyModemConfig() repairs it with
// error/critical-error side effects — or, for the swappable EU trio, the clamp would
// flip the region right back. The user picked the region, so the preset follows it.
const RegionInfo *newRegion = getRegion(region);
if (config.lora.use_preset && !newRegion->supportsPreset(config.lora.modem_preset)) {
LOG_INFO("Preset %s not available in %s, using default %s",
DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true), newRegion->name,
DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true));
config.lora.modem_preset = newRegion->getDefaultPreset();
}
if (isHam && adminModule) { if (isHam && adminModule) {
meshtastic_HamParameters hamParams = meshtastic_HamParameters_init_zero; meshtastic_HamParameters hamParams = meshtastic_HamParameters_init_zero;
strncpy(hamParams.call_sign, "N0CALL", sizeof(hamParams.call_sign) - 1); strncpy(hamParams.call_sign, "N0CALL", sizeof(hamParams.call_sign) - 1);
@@ -199,7 +211,7 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool
config.lora.ignore_mqtt = true; config.lora.ignore_mqtt = true;
} }
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name); snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
changes |= SEGMENT_MODULECONFIG; changes |= SEGMENT_MODULECONFIG;
} }
service->reloadConfig(changes); service->reloadConfig(changes);
@@ -263,13 +275,17 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
return; return;
} }
// Guard: without a reboot, reconfigure() applies the region directly. // Guard: without a reboot, reconfigure() applies the region directly, so reject
// Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot. // regions this node can't use up front: unrecognized codes, licensed-only regions,
// TODO: change this to either use the validateLoraConfig() logic or at least check the region for wideLora // and radio hardware mismatches (2.4 GHz vs sub-GHz) — the same checks the admin
// rather than a hardcoded check for LORA_24. // set-config path applies, but side-effect-free: ignoring a menu selection should
if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 && // not record a critical error or notify clients. getRadio() used to catch hardware
!(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) { // mismatches post-reboot only.
LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring region selection"); auto candidateLora = config.lora;
candidateLora.region = selectedRegion;
char regionErr[160];
if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr))) {
LOG_WARN("Ignoring region selection: %s", regionErr);
return; return;
} }
@@ -287,7 +287,7 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region)
} }
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) {
sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name); snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
changes |= SEGMENT_MODULECONFIG; changes |= SEGMENT_MODULECONFIG;
} }
// Notify UI that changes are being applied // Notify UI that changes are being applied
+8
View File
@@ -65,6 +65,14 @@ struct RegionInfo {
// Preset accessors (delegate through profile) // Preset accessors (delegate through profile)
meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return defaultPreset; } meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return defaultPreset; }
const meshtastic_Config_LoRaConfig_ModemPreset *getAvailablePresets() const { return profile->presets; } const meshtastic_Config_LoRaConfig_ModemPreset *getAvailablePresets() const { return profile->presets; }
bool supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset preset) const
{
for (size_t i = 0; profile->presets[i] != MODEM_PRESET_END; i++) {
if (profile->presets[i] == preset)
return true;
}
return false;
}
size_t getNumPresets() const size_t getNumPresets() const
{ {
size_t n = 0; size_t n = 0;
+111 -34
View File
@@ -97,8 +97,18 @@ const RegionInfo regions[] = {
500 mW, or 27 dBm. It also states that we can use interference avoidance and spectrum access techniques (such as LBT + 500 mW, or 27 dBm. It also states that we can use interference avoidance and spectrum access techniques (such as LBT +
AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.) AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.)
https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf
EU 866MHz band (Band no. 46b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD)
Gives 4 channels at 865.7/866.3/866.9/867.5 MHz, 400 kHz gap plus 37.5 kHz padding between channels, 27 dBm,
duty cycle 2.5% (mobile) or 10% (fixed) https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02006D0771(01)-20250123
EU 868MHz band: 3 channels at 869.410/869.4625/869.577 MHz
Channel centres at 869.442/869.525/869.608 MHz,
10.4 kHz padding on channels, 27 dBm, duty cycle 10%
*/ */
RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868, PRESET(LONG_FAST), 0), RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868, PRESET(LONG_FAST), 0),
RDEF(EU_866, 865.6f, 867.6f, 2.5, 27, false, false, PROFILE_LITE, PRESET(LITE_FAST), 0),
RDEF(EU_N_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_NARROW, PRESET(NARROW_SLOW), 1),
/* /*
https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf
@@ -276,20 +286,6 @@ const RegionInfo regions[] = {
*/ */
RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD, PRESET(LONG_FAST), 0), RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD, PRESET(LONG_FAST), 0),
/*
EU 866MHz band (Band no. 46b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD)
Gives 4 channels at 865.7/866.3/866.9/867.5 MHz, 400 kHz gap plus 37.5 kHz padding between channels, 27 dBm,
duty cycle 2.5% (mobile) or 10% (fixed) https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02006D0771(01)-20250123
*/
RDEF(EU_866, 865.6f, 867.6f, 2.5, 27, false, false, PROFILE_LITE, PRESET(LITE_FAST), 0),
/*
EU 868MHz band: 3 channels at 869.410/869.4625/869.577 MHz
Channel centres at 869.442/869.525/869.608 MHz,
10.4 kHz padding on channels, 27 dBm, duty cycle 10%
*/
RDEF(EU_N_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_NARROW, PRESET(NARROW_SLOW), 1),
/* /*
This needs to be last. Same as US. This needs to be last. Same as US.
*/ */
@@ -857,53 +853,116 @@ uint32_t RadioInterface::getChannelNum()
} }
/** /**
* Send an error-level client notification. Safe to call when service is null (e.g. in tests). * Send a client notification (error level unless specified). Safe to call when service is null (e.g. in tests).
*/ */
static void sendErrorNotification(const char *msg) static void sendErrorNotification(const char *msg, meshtastic_LogRecord_Level level = meshtastic_LogRecord_Level_ERROR)
{ {
if (!service) if (!service)
return; return;
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
if (!cn) if (!cn)
return; return;
cn->level = meshtastic_LogRecord_Level_ERROR; cn->level = level;
snprintf(cn->message, sizeof(cn->message), "%s", msg); snprintf(cn->message, sizeof(cn->message), "%s", msg);
service->sendClientNotification(cn); service->sendClientNotification(cn);
} }
// The EU_868/EU_866/EU_N_868 trio own mutually exclusive preset lists. Selecting a preset
// locked to a sibling means the user wants that sibling region, not the default preset.
static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = {
meshtastic_Config_LoRaConfig_RegionCode_EU_868,
meshtastic_Config_LoRaConfig_RegionCode_EU_866,
meshtastic_Config_LoRaConfig_RegionCode_EU_N_868,
};
/** /**
* Checks if a region is valid for the current settings. * If currentRegion is one of the swappable EU regions and preset belongs to a sibling in
* that trio, return the sibling region that owns the preset. Returns nullptr otherwise.
*/
const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConfig_RegionCode currentRegion,
meshtastic_Config_LoRaConfig_ModemPreset preset)
{
bool currentIsSwappable = false;
for (auto code : SWAPPABLE_EU_REGIONS) {
if (code == currentRegion)
currentIsSwappable = true;
}
if (!currentIsSwappable)
return nullptr;
for (auto code : SWAPPABLE_EU_REGIONS) {
if (code == currentRegion)
continue;
const RegionInfo *sibling = getRegion(code);
if (sibling->supportsPreset(preset))
return sibling;
}
return nullptr;
}
/**
* Checks if a region is valid for the current settings, with no side effects.
* Safe to call speculatively (e.g. from UI pickers). When errBuf is given, it
* receives the human-readable failure reason.
* Returns false if not compatible. * Returns false if not compatible.
*/ */
bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig) bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf, size_t errLen)
{ {
const RegionInfo *newRegion = getRegion(loraConfig.region); const RegionInfo *newRegion = getRegion(loraConfig.region);
// Reject unrecognized region codes (getRegion returns UNSET sentinel for unknown codes) // Reject unrecognized region codes (getRegion returns UNSET sentinel for unknown codes)
if (newRegion->code != loraConfig.region) { if (newRegion->code != loraConfig.region) {
char err_string[160]; if (errBuf)
snprintf(err_string, sizeof(err_string), "Region code %d is not recognized", loraConfig.region); snprintf(errBuf, errLen, "Region code %d is not recognized", loraConfig.region);
LOG_ERROR("%s", err_string);
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
sendErrorNotification(err_string);
return false; return false;
} }
// If you are not licensed, you can't use ham regions. // If you are not licensed, you can't use ham regions.
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) { if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) {
char err_string[160]; if (errBuf)
snprintf(err_string, sizeof(err_string), "Region %s requires licensed mode", newRegion->name); snprintf(errBuf, errLen, "Region %s requires licensed mode", newRegion->name);
LOG_ERROR("%s", err_string);
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
sendErrorNotification(err_string);
return false; return false;
} }
// Hardware compatibility: wide-LoRa (2.4 GHz) regions need a wide-capable radio, and
// sub-GHz regions need a radio that can tune below 2.4 GHz (SX128x cannot). UNSET is
// always allowed since it is the "no region" state.
if (newRegion->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && RadioLibInterface::instance) {
const char *unsupported = nullptr;
if (newRegion->wideLora && !RadioLibInterface::instance->wideLora()) {
unsupported = "2.4 GHz";
} else if (!newRegion->wideLora && !RadioLibInterface::instance->supportsSubGhz()) {
unsupported = "sub-GHz";
}
if (unsupported) {
if (errBuf)
snprintf(errBuf, errLen, "Region %s needs %s, which this radio does not support", newRegion->name, unsupported);
return false;
}
}
return true; return true;
} }
/** /**
* Internal helper: validate or clamp a LoRa config against its region. * Checks if a region is valid for the current settings. On failure, logs at ERROR,
* records a critical error, and sends a client notification.
* Returns false if not compatible.
*/
bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig)
{
char err_string[160];
if (checkConfigRegion(loraConfig, err_string, sizeof(err_string)))
return true;
LOG_ERROR("%s", err_string);
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
sendErrorNotification(err_string);
return false;
}
/**
* Internal helper: check or clamp a LoRa config against its region.
* When clamp==false, returns false on first error (pure validation). * When clamp==false, returns false on first error (pure validation).
* When clamp==true, fixes invalid settings in-place and returns true. * When clamp==true, fixes invalid settings in-place and returns true.
*/ */
@@ -920,11 +979,29 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo
if (loraConfig.use_preset) { if (loraConfig.use_preset) {
check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora); check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora);
bool preset_valid = false; bool preset_valid = newRegion->supportsPreset(loraConfig.modem_preset);
for (size_t i = 0; i < newRegion->getNumPresets(); i++) { if (!preset_valid) {
if (loraConfig.modem_preset == newRegion->getAvailablePresets()[i]) { // A preset locked to a sibling of the swappable EU regions swaps the region instead
// of clamping the preset, as long as the previous region was itself one of the trio.
const RegionInfo *swapRegion = regionSwapForPreset(loraConfig.region, loraConfig.modem_preset);
if (swapRegion) {
if (!clamp) {
// Validation must still fail so callers route into the clamp, but quietly:
// the clamp will accept this config by swapping regions, so don't record a
// critical error or alarm the user over a change that is about to succeed.
LOG_INFO("Preset %s implies region swap %s to %s, deferring to clamp", presetName, newRegion->name,
swapRegion->name);
return false;
}
snprintf(err_string, sizeof(err_string), "Preset %s swaps region %s to %s", presetName, newRegion->name,
swapRegion->name);
LOG_INFO("%s", err_string);
sendErrorNotification(err_string, meshtastic_LogRecord_Level_INFO);
loraConfig.region = swapRegion->code;
newRegion = swapRegion;
check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora);
preset_valid = true; preset_valid = true;
break;
} }
} }
if (!preset_valid) { if (!preset_valid) {
+15 -1
View File
@@ -143,6 +143,10 @@ class RadioInterface
virtual bool wideLora() { return false; } virtual bool wideLora() { return false; }
/// Whether the radio can tune sub-GHz bands. False for 2.4 GHz-only chips (SX128x);
/// multiband chips like the LR1121 keep the default.
virtual bool supportsSubGhz() { return true; }
/// Prepare hardware for sleep. Call this _only_ for deep sleep, not needed for light sleep. /// Prepare hardware for sleep. Call this _only_ for deep sleep, not needed for light sleep.
virtual bool sleep() { return true; } virtual bool sleep() { return true; }
@@ -244,7 +248,12 @@ class RadioInterface
static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp); static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp);
// Check if a candidate region is compatible and valid. // Check if a candidate region is compatible and valid, with no side effects (safe for
// speculative UI checks). errBuf, if given, receives the failure reason.
static bool checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf = nullptr, size_t errLen = 0);
// Check if a candidate region is compatible and valid. On failure, logs at ERROR,
// records a critical error, and sends a client notification.
static bool validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig); static bool validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig);
// Check if a candidate radio configuration is valid. // Check if a candidate radio configuration is valid.
@@ -253,6 +262,11 @@ class RadioInterface
// Make a candidate radio configuration valid, even if it isn't. // Make a candidate radio configuration valid, even if it isn't.
static void clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig); static void clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig);
// If preset is locked to a sibling of currentRegion among the swappable EU regions
// (EU_868/EU_866/EU_N_868), return the sibling region owning the preset, else nullptr.
static const RegionInfo *regionSwapForPreset(meshtastic_Config_LoRaConfig_RegionCode currentRegion,
meshtastic_Config_LoRaConfig_ModemPreset preset);
protected: protected:
int8_t power = 17; // Set by applyModemConfig() int8_t power = 17; // Set by applyModemConfig()
+3
View File
@@ -19,6 +19,9 @@ template <class T> class SX128xInterface : public RadioLibInterface
virtual bool wideLora() override; virtual bool wideLora() override;
/// SX128x is a 2.4 GHz-only chip; it cannot tune sub-GHz regions
virtual bool supportsSubGhz() override { return false; }
/// Apply any radio provisioning changes /// Apply any radio provisioning changes
/// Make sure the Driver is properly configured before calling init(). /// Make sure the Driver is properly configured before calling init().
/// \return true if initialisation succeeded. /// \return true if initialisation succeeded.
+29 -3
View File
@@ -829,7 +829,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
} }
if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { 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 // 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); snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
} }
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG; changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
} else { } else {
@@ -840,13 +840,39 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
if (!RadioInterface::validateConfigLora(validatedLora)) { if (!RadioInterface::validateConfigLora(validatedLora)) {
if (fromOthers) { if (fromOthers) {
// A preset locked to a sibling EU region still swaps the region for remote admin;
// any other invalid config is rejected outright.
const RegionInfo *swapRegion =
validatedLora.use_preset
? RadioInterface::regionSwapForPreset(validatedLora.region, validatedLora.modem_preset)
: NULL;
if (swapRegion) {
validatedLora.region = swapRegion->code;
}
if (!swapRegion || !RadioInterface::validateConfigLora(validatedLora)) {
LOG_WARN("Invalid LoRa config received from another node, rejecting changes"); LOG_WARN("Invalid LoRa config received from another node, rejecting changes");
// modem_preset set to use the old setting if the check fails // Rejecting means rejecting everything: a partial restore of region/preset
validatedLora.modem_preset = oldLoraConfig.modem_preset; // could still apply other fields the validation already deemed invalid.
validatedLora = oldLoraConfig;
}
} else { } else {
LOG_WARN("Invalid LoRa config received from client, using corrected values"); LOG_WARN("Invalid LoRa config received from client, using corrected values");
RadioInterface::clampConfigLora(validatedLora); RadioInterface::clampConfigLora(validatedLora);
} }
// A preset locked to a sibling EU region swaps the region during the clamp;
// apply the same housekeeping as an explicit region change.
if (validatedLora.region != oldLoraConfig.region) {
config.lora.region = validatedLora.region;
initRegion();
if (getEffectiveDutyCycle() < 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
snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name);
}
changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG;
}
// use_preset and bandwidth are coerced into valid values by the check. // use_preset and bandwidth are coerced into valid values by the check.
} }
+1 -1
View File
@@ -85,7 +85,7 @@ The native build requires several system libraries. Install them all at once:
```bash ```bash
sudo apt-get install -y \ sudo apt-get install -y \
libbluetooth-dev libgpiod-dev libyaml-cpp-dev openssl libssl-dev \ libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev \
libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev
``` ```
+185
View File
@@ -707,6 +707,91 @@ static void test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault()
TEST_ASSERT_EQUAL(lora24->getDefaultPreset(), cfg.modem_preset); TEST_ASSERT_EQUAL(lora24->getDefaultPreset(), cfg.modem_preset);
} }
// -----------------------------------------------------------------------
// Region-locked preset swap tests (EU_868 / EU_866 / EU_N_868 trio)
// -----------------------------------------------------------------------
static void test_clampConfigLora_narrowPresetOnEU866SwapsToEUN868()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, cfg.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST, cfg.modem_preset);
}
static void test_clampConfigLora_litePresetOnEU868SwapsToEU866()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_866, cfg.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW, cfg.modem_preset);
}
static void test_clampConfigLora_eu868PresetOnEUN868SwapsToEU868()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_N_868;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, cfg.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset);
}
static void test_clampConfigLora_litePresetOnUSDoesNotSwap()
{
// Previous region is not one of the swappable trio, so the preset clamps to the
// region default instead of swapping regions.
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST;
RadioInterface::clampConfigLora(cfg);
const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, cfg.region);
TEST_ASSERT_EQUAL(us->getDefaultPreset(), cfg.modem_preset);
}
static void test_clampConfigLora_narrowPresetOnHam125cmDoesNotSwap()
{
// ITU2_125CM shares the NARROW presets, so they are valid there and nothing changes
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW;
RadioInterface::clampConfigLora(cfg);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM, cfg.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW, cfg.modem_preset);
}
static void test_validateConfigLora_siblingLockedPresetStillFailsValidation()
{
// Validation (no clamp) must keep failing so callers route into clampConfigLora,
// which performs the region swap.
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866;
cfg.use_preset = true;
cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST;
TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg));
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// RegionInfo preset list integrity tests // RegionInfo preset list integrity tests
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -921,6 +1006,93 @@ static void test_handleSetConfig_fromOthers_validPresetAccepted()
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset);
} }
static void test_handleSetConfig_fromOthers_invalidChannelNumFullyRejected()
{
// Rejecting a remote config must reject ALL of it: an invalid channel_num must not
// leak into config.lora alongside the restored region/preset.
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
config.lora.channel_num = 0;
initRegion();
meshtastic_Config c =
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST);
c.payload_variant.lora.channel_num = 5000; // far beyond US slot count
testAdmin->handleSetConfig(c, true); // fromOthers = true
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
TEST_ASSERT_EQUAL_UINT32(0, config.lora.channel_num);
}
static void test_regionInfo_supportsPreset()
{
const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868);
TEST_ASSERT_TRUE(eu868->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST));
TEST_ASSERT_FALSE(eu868->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO));
TEST_ASSERT_FALSE(eu868->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST));
const RegionInfo *eu866 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866);
TEST_ASSERT_TRUE(eu866->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW));
TEST_ASSERT_FALSE(eu866->supportsPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST));
}
static void test_checkConfigRegion_quietCheckReportsReason()
{
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US;
TEST_ASSERT_TRUE(RadioInterface::checkConfigRegion(cfg));
cfg.region = (meshtastic_Config_LoRaConfig_RegionCode)254;
char err[160] = {0};
TEST_ASSERT_FALSE(RadioInterface::checkConfigRegion(cfg, err, sizeof(err)));
TEST_ASSERT_TRUE_MESSAGE(strlen(err) > 0, "Expected a failure reason in errBuf");
}
static void test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion()
{
// Baseline: EU_866 (LITE profile)
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_866;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST;
initRegion();
// Remote admin keeps the region but selects a NARROW preset (locked to EU_N_868)
meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_866, true,
meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST);
testAdmin->handleSetConfig(c, true); // fromOthers = true
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, config.lora.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST, config.lora.modem_preset);
// Restore the region table pointer for subsequent tests
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
initRegion();
}
static void test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected()
{
// Baseline: US is not one of the swappable trio, so a LITE preset must be rejected
config.lora = meshtastic_Config_LoRaConfig_init_zero;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
config.lora.use_preset = true;
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
initRegion();
meshtastic_Config c =
makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST);
testAdmin->handleSetConfig(c, true); // fromOthers = true
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region);
TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Test runner // Test runner
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -992,6 +1164,14 @@ void setup()
RUN_TEST(test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast); RUN_TEST(test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast);
RUN_TEST(test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault); RUN_TEST(test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault);
// Region-locked preset swap
RUN_TEST(test_clampConfigLora_narrowPresetOnEU866SwapsToEUN868);
RUN_TEST(test_clampConfigLora_litePresetOnEU868SwapsToEU866);
RUN_TEST(test_clampConfigLora_eu868PresetOnEUN868SwapsToEU868);
RUN_TEST(test_clampConfigLora_litePresetOnUSDoesNotSwap);
RUN_TEST(test_clampConfigLora_narrowPresetOnHam125cmDoesNotSwap);
RUN_TEST(test_validateConfigLora_siblingLockedPresetStillFailsValidation);
// RegionInfo preset list integrity // RegionInfo preset list integrity
RUN_TEST(test_presetsStd_hasNineEntries); RUN_TEST(test_presetsStd_hasNineEntries);
RUN_TEST(test_presetsEU868_hasSevenEntries); RUN_TEST(test_presetsEU868_hasSevenEntries);
@@ -1016,6 +1196,11 @@ void setup()
RUN_TEST(test_handleSetConfig_fromOthers_invalidPresetRejected); RUN_TEST(test_handleSetConfig_fromOthers_invalidPresetRejected);
RUN_TEST(test_handleSetConfig_fromLocal_invalidPresetClamped); RUN_TEST(test_handleSetConfig_fromLocal_invalidPresetClamped);
RUN_TEST(test_handleSetConfig_fromOthers_validPresetAccepted); RUN_TEST(test_handleSetConfig_fromOthers_validPresetAccepted);
RUN_TEST(test_handleSetConfig_fromOthers_invalidChannelNumFullyRejected);
RUN_TEST(test_regionInfo_supportsPreset);
RUN_TEST(test_checkConfigRegion_quietCheckReportsReason);
RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion);
RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected);
exit(UNITY_END()); exit(UNITY_END());
} }