From ab882c5619caf578cb845d6403d79adc2bbff3eb Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:37:14 +0100 Subject: [PATCH] EU regions merge (#10675) * stronger together * validate 2.4ghz regions * less noise * you're right, and that shapens the analysis significantly * sassy rejoinder --- src/graphics/draw/MenuHandler.cpp | 32 ++- .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 2 +- src/mesh/MeshRadio.h | 8 + src/mesh/RadioInterface.cpp | 167 +++++++++++----- src/mesh/RadioInterface.h | 16 +- src/mesh/SX128xInterface.h | 3 + src/modules/AdminModule.cpp | 34 +++- test/README.md | 2 +- test/test_admin_radio/test_main.cpp | 185 ++++++++++++++++++ 9 files changed, 389 insertions(+), 60 deletions(-) diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 9157dc31d..365739112 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -180,6 +180,18 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool config.lora.region = region; 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) { meshtastic_HamParameters hamParams = meshtastic_HamParameters_init_zero; 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; } 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; } service->reloadConfig(changes); @@ -263,13 +275,17 @@ void menuHandler::LoraRegionPicker(uint32_t duration) return; } - // Guard: without a reboot, reconfigure() applies the region directly. - // Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot. - // TODO: change this to either use the validateLoraConfig() logic or at least check the region for wideLora - // rather than a hardcoded check for LORA_24. - if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 && - !(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) { - LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring region selection"); + // Guard: without a reboot, reconfigure() applies the region directly, so reject + // regions this node can't use up front: unrecognized codes, licensed-only regions, + // and radio hardware mismatches (2.4 GHz vs sub-GHz) — the same checks the admin + // set-config path applies, but side-effect-free: ignoring a menu selection should + // not record a critical error or notify clients. getRadio() used to catch hardware + // mismatches post-reboot only. + 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; } diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index f588cb28b..12dde48e9 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -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) { - 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; } // Notify UI that changes are being applied diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 6d914c489..965f3c46e 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -65,6 +65,14 @@ struct RegionInfo { // Preset accessors (delegate through profile) meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return defaultPreset; } 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 n = 0; diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 5bd87b30b..a28335c19 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -85,20 +85,30 @@ const RegionInfo regions[] = { */ RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD, PRESET(LONG_FAST), 0), /* - https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/ - https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/ - https://www.legislation.gov.uk/uksi/1999/930/schedule/6/part/III/made/data.xht?view=snippet&wrap=true + https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/ + https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/ + https://www.legislation.gov.uk/uksi/1999/930/schedule/6/part/III/made/data.xht?view=snippet&wrap=true - audio_permitted = false per regulation + audio_permitted = false per regulation - Special Note: - The link above describes LoRaWAN's band plan, stating a power limit of 16 dBm. This is their own suggested specification, - we do not need to follow it. The European Union regulations clearly state that the power limit for this frequency range is - 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.) - https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf - */ + Special Note: + The link above describes LoRaWAN's band plan, stating a power limit of 16 dBm. This is their own suggested specification, + we do not need to follow it. The European Union regulations clearly state that the power limit for this frequency range is + 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.) + 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_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 @@ -276,20 +286,6 @@ const RegionInfo regions[] = { */ 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. */ @@ -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) return; meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); if (!cn) return; - cn->level = meshtastic_LogRecord_Level_ERROR; + cn->level = level; snprintf(cn->message, sizeof(cn->message), "%s", msg); 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. */ -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); // Reject unrecognized region codes (getRegion returns UNSET sentinel for unknown codes) if (newRegion->code != loraConfig.region) { - char err_string[160]; - snprintf(err_string, sizeof(err_string), "Region code %d is not recognized", loraConfig.region); - LOG_ERROR("%s", err_string); - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - sendErrorNotification(err_string); + if (errBuf) + snprintf(errBuf, errLen, "Region code %d is not recognized", loraConfig.region); return false; } // If you are not licensed, you can't use ham regions. if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) { - char err_string[160]; - snprintf(err_string, sizeof(err_string), "Region %s requires licensed mode", newRegion->name); - LOG_ERROR("%s", err_string); - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - sendErrorNotification(err_string); + if (errBuf) + snprintf(errBuf, errLen, "Region %s requires licensed mode", newRegion->name); 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; } /** - * 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==true, fixes invalid settings in-place and returns true. */ @@ -920,11 +979,29 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo if (loraConfig.use_preset) { check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora); - bool preset_valid = false; - for (size_t i = 0; i < newRegion->getNumPresets(); i++) { - if (loraConfig.modem_preset == newRegion->getAvailablePresets()[i]) { + bool preset_valid = newRegion->supportsPreset(loraConfig.modem_preset); + if (!preset_valid) { + // 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; - break; } } if (!preset_valid) { diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index d987ef766..c199572c4 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -143,6 +143,10 @@ class RadioInterface 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. virtual bool sleep() { return true; } @@ -244,7 +248,12 @@ class RadioInterface 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); // 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. 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: int8_t power = 17; // Set by applyModemConfig() diff --git a/src/mesh/SX128xInterface.h b/src/mesh/SX128xInterface.h index cf44bcb89..1205087b7 100644 --- a/src/mesh/SX128xInterface.h +++ b/src/mesh/SX128xInterface.h @@ -19,6 +19,9 @@ template class SX128xInterface : public RadioLibInterface 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 /// Make sure the Driver is properly configured before calling init(). /// \return true if initialisation succeeded. diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 2ca5100d0..85e66fe96 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -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) { // 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; } else { @@ -840,13 +840,39 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) 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; + // 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"); + // Rejecting means rejecting everything: a partial restore of region/preset + // could still apply other fields the validation already deemed invalid. + validatedLora = oldLoraConfig; + } } else { LOG_WARN("Invalid LoRa config received from client, using corrected values"); 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. } diff --git a/test/README.md b/test/README.md index 05d6878f5..b9f6b961a 100644 --- a/test/README.md +++ b/test/README.md @@ -85,7 +85,7 @@ The native build requires several system libraries. Install them all at once: ```bash 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 ``` diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index bfcc73b57..d905cc8a0 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -707,6 +707,91 @@ static void test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault() 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 // ----------------------------------------------------------------------- @@ -921,6 +1006,93 @@ static void test_handleSetConfig_fromOthers_validPresetAccepted() 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 // ----------------------------------------------------------------------- @@ -992,6 +1164,14 @@ void setup() RUN_TEST(test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast); 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 RUN_TEST(test_presetsStd_hasNineEntries); RUN_TEST(test_presetsEU868_hasSevenEntries); @@ -1016,6 +1196,11 @@ void setup() RUN_TEST(test_handleSetConfig_fromOthers_invalidPresetRejected); RUN_TEST(test_handleSetConfig_fromLocal_invalidPresetClamped); 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()); }