diff --git a/src/Power.cpp b/src/Power.cpp index a886082c609..83a5a5d0c74 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -15,6 +15,7 @@ */ #include "Power.h" #include "BluetoothCommon.h" +#include "MeshService.h" #include "MessageStore.h" #include "NodeDB.h" #include "PowerFSM.h" @@ -828,6 +829,9 @@ bool Power::setup() void Power::powerCommandsCheck() { + if (service && service->loRaConfigApplyActive()) + return; + if (rebootAtMsec && millis() > rebootAtMsec) { LOG_INFO("Rebooting"); reboot(); diff --git a/src/concurrency/NotifiedWorkerThread.cpp b/src/concurrency/NotifiedWorkerThread.cpp index 29aff32a504..e1a5bbb19f5 100644 --- a/src/concurrency/NotifiedWorkerThread.cpp +++ b/src/concurrency/NotifiedWorkerThread.cpp @@ -74,6 +74,14 @@ bool NotifiedWorkerThread::notifyLater(uint32_t delay, uint32_t v, bool overwrit return didIt; } +void NotifiedWorkerThread::wakePreservingNotification() +{ + enabled = true; + setInterval(0); + runASAP = true; + mainDelay.interrupt(); +} + void NotifiedWorkerThread::checkNotification() { // Atomically read and clear. (This avoids a potential race condition where an interrupt handler could set a new notification @@ -93,4 +101,4 @@ int32_t NotifiedWorkerThread::runOnce() return RUN_SAME; } -} // namespace concurrency \ No newline at end of file +} // namespace concurrency diff --git a/src/concurrency/NotifiedWorkerThread.h b/src/concurrency/NotifiedWorkerThread.h index 166b9ea65a1..905881fbd6c 100644 --- a/src/concurrency/NotifiedWorkerThread.h +++ b/src/concurrency/NotifiedWorkerThread.h @@ -39,6 +39,9 @@ class NotifiedWorkerThread : public OSThread protected: virtual void onNotify(uint32_t notification) = 0; + /** Wake immediately without replacing an already queued notification. */ + void wakePreservingNotification(); + /// just calls checkNotification() virtual int32_t runOnce() override; diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index d4ff187ecc0..cc6a747aa11 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -126,6 +126,16 @@ void launchReplyForMessage(const StoredMessage &message, bool freetext) } } +bool requestMenuLoRaConfig(const meshtastic_Config_LoRaConfig &candidate, + AdminModule::MenuLoRaTransition transition = AdminModule::MenuLoRaTransition::NONE) +{ + if (!adminModule || !adminModule->requestMenuLoRaConfig(candidate, transition)) { + LOG_WARN("Unable to queue LoRa configuration change from menu"); + return false; + } + return true; +} + } // namespace menuHandler::screenMenus menuHandler::menuQueue = MenuNone; @@ -179,51 +189,26 @@ void menuHandler::OnboardMessage() screen->showOverlayBanner(bannerOptions); } -static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool isHam) +static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, + AdminModule::MenuLoRaTransition transition = AdminModule::MenuLoRaTransition::NONE) { - config.lora.region = region; - config.lora.channel_num = 0; // Reset to default channel + auto candidate = config.lora; + candidate.region = region; + candidate.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)) { + if (candidate.use_preset && !newRegion->supportsPreset(candidate.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(candidate.modem_preset, false, true), newRegion->name, DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true)); - config.lora.modem_preset = newRegion->getDefaultPreset(); + candidate.modem_preset = newRegion->getDefaultPreset(); } - if (isHam && adminModule) { - meshtastic_HamParameters hamParams = meshtastic_HamParameters_init_zero; - strncpy(hamParams.call_sign, "N0CALL", sizeof(hamParams.call_sign) - 1); - strncpy(hamParams.short_name, "N0CL", sizeof(hamParams.short_name)); - hamParams.tx_power = config.lora.tx_power; - hamParams.frequency = config.lora.override_frequency; - adminModule->handleSetHamMode(hamParams); - } - auto changes = SEGMENT_CONFIG; -#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (crypto) { - crypto->ensurePkiKeys(config.security, owner); - } -#endif - initRegion(); - if (getEffectiveDutyCycle() < 100) { - config.lora.ignore_mqtt = true; - } - if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { - snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name); - changes |= SEGMENT_MODULECONFIG; - } -#if !MESHTASTIC_EXCLUDE_GPS - // Enable gps if it was previously disabled due to region not being set - if (gps != nullptr && !gps->isEnabled() && config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) - gps->enable(); -#endif - service->reloadConfig(changes); + requestMenuLoRaConfig(candidate, transition); } void menuHandler::LoraRegionPicker(uint32_t duration) @@ -321,7 +306,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration) menuQueue = LicensedToNormalConfirm; screen->runNow(); } else { - applyLoraRegion(selectedRegion, false); + applyLoraRegion(selectedRegion); } }); @@ -348,7 +333,7 @@ void menuHandler::hamModeConfirmMenu() confirmBanner.optionsCount = 2; confirmBanner.bannerCallback = [](int selected) { if (selected == 1) - applyLoraRegion(pendingRegion, true); + applyLoraRegion(pendingRegion, AdminModule::MenuLoRaTransition::ENTER_LICENSED); }; screen->showOverlayBanner(confirmBanner); } @@ -361,12 +346,8 @@ void menuHandler::licensedToNormalConfirmMenu() confirmBanner.optionsArrayPtr = confirmOptions; confirmBanner.optionsCount = 2; confirmBanner.bannerCallback = [](int selected) { - if (selected == 1) { - owner.is_licensed = false; - config.lora.override_duty_cycle = false; - service->reloadOwner(false); - } - applyLoraRegion(pendingRegion, false); + if (selected == 1) + applyLoraRegion(pendingRegion, AdminModule::MenuLoRaTransition::EXIT_LICENSED); }; screen->showOverlayBanner(confirmBanner); } @@ -471,8 +452,9 @@ void menuHandler::FrequencySlotPicker() return; } - config.lora.channel_num = selected; - service->reloadConfig(SEGMENT_CONFIG); + auto candidate = config.lora; + candidate.channel_num = selected; + requestMenuLoRaConfig(candidate); }; screen->showOverlayBanner(bannerOptions); @@ -524,11 +506,12 @@ static BannerOverlayOptions buildRegionPresetBanner() screen->runNow(); return; } - config.lora.use_preset = true; - config.lora.modem_preset = static_cast(selected); - config.lora.channel_num = 0; // Reset to default channel for the preset - config.lora.override_frequency = 0; // Clear any custom frequency - service->reloadConfig(SEGMENT_CONFIG); + auto candidate = config.lora; + candidate.use_preset = true; + candidate.modem_preset = static_cast(selected); + candidate.channel_num = 0; // Reset to default channel for the preset + candidate.override_frequency = 0; // Clear any custom frequency + requestMenuLoRaConfig(candidate); }; return bannerOptions; } diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 5e8a08e7511..a942b6d9e2f 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -15,6 +15,7 @@ #include "graphics/niche/Utils/FlashData.h" #include "main.h" #include "mesh/generated/meshtastic/deviceonly.pb.h" +#include "modules/AdminModule.h" #include #include #if defined(ARCH_ESP32) && HAS_WIFI @@ -314,38 +315,25 @@ static constexpr uint8_t MAX_REGION_PRESETS = 16; static meshtastic_Config_LoRaConfig_ModemPreset regionPresets[MAX_REGION_PRESETS]; static uint8_t regionPresetCount = 0; +static bool requestMenuLoRaConfig(const meshtastic_Config_LoRaConfig &candidate) +{ + if (!adminModule || !adminModule->requestMenuLoRaConfig(candidate)) { + LOG_WARN("Unable to queue LoRa configuration change from InkHUD menu"); + return false; + } + InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); + return true; +} + static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region) { if (config.lora.region == region) return; - config.lora.region = region; - - auto changes = SEGMENT_CONFIG; - -#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (crypto) { - crypto->ensurePkiKeys(config.security, owner); - } -#endif - - config.lora.tx_enabled = true; - - initRegion(); - - if (myRegion && getEffectiveDutyCycle() < 100) { - config.lora.ignore_mqtt = true; - } - - if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { - 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 - InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - service->reloadConfig(changes); - - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + auto candidate = config.lora; + candidate.region = region; + candidate.tx_enabled = true; + requestMenuLoRaConfig(candidate); } static void applyDeviceRole(meshtastic_Config_DeviceConfig_Role role) @@ -370,16 +358,12 @@ static void applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset preset) if (config.lora.modem_preset == preset) return; - config.lora.use_preset = true; - config.lora.modem_preset = preset; - - nodeDB->saveToDisk(SEGMENT_CONFIG); - service->reloadConfig(SEGMENT_CONFIG); - - // Notify UI that changes are being applied - InkHUD::InkHUD::getInstance()->notifyApplyingChanges(); - - rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000; + auto candidate = config.lora; + candidate.use_preset = true; + candidate.modem_preset = preset; + candidate.channel_num = 0; + candidate.override_frequency = 0; + requestMenuLoRaConfig(candidate); } static void applyConfigReload(uint32_t changes = SEGMENT_CONFIG, bool reboot = false) diff --git a/src/main.cpp b/src/main.cpp index bdac9c2f80d..c0a2f549cbe 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1447,6 +1447,10 @@ void loop() #endif service->loop(); +#if defined(ARCH_PORTDUINO) && !defined(ARCH_PORTDUINO_WASM) && __has_include() + if (piwebServerThread) + piwebServerThread->processPendingRequests(); +#endif #if !MESHTASTIC_EXCLUDE_INPUTBROKER && defined(HAS_FREE_RTOS) && !defined(ARCH_RP2040) if (inputBroker) inputBroker->processInputEventQueue(); @@ -1473,7 +1477,7 @@ void loop() exit(EXIT_FAILURE); } } - auto rIf = initLoRa(); + auto rIf = initLoRa(!service->loRaConfigApplyActive()); if (rIf) { router->addInterface(std::move(rIf)); portduino_status.LoRa_in_error = false; diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index 2d8d4f246ce..62d7c28c9e8 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -117,12 +117,17 @@ void Channels::initDefaultLoraConfig() bool Channels::ensureLicensedOperation() { - if (!owner.is_licensed) { + return ensureLicensedOperation(channelFile, owner.is_licensed); +} + +bool Channels::ensureLicensedOperation(meshtastic_ChannelFile &file, bool licensed) +{ + if (!licensed) return false; - } + bool hasEncryptionOrAdmin = false; - for (uint8_t i = 0; i < MAX_NUM_CHANNELS; i++) { - auto channel = channels.getByIndex(i); + for (pb_size_t i = 0; i < file.channels_count; ++i) { + auto &channel = file.channels[i]; if (!channel.has_settings) { continue; } @@ -133,14 +138,12 @@ bool Channels::ensureLicensedOperation() channelSettings.psk.bytes[0] = 0; channelSettings.psk.size = 0; hasEncryptionOrAdmin = true; - channels.setChannel(channel); } } else if (channelSettings.psk.size > 0) { channelSettings.psk.bytes[0] = 0; channelSettings.psk.size = 0; hasEncryptionOrAdmin = true; - channels.setChannel(channel); } } return hasEncryptionOrAdmin; @@ -151,7 +154,12 @@ bool Channels::ensureLicensedOperation() */ void Channels::initDefaultChannel(ChannelIndex chIndex) { - meshtastic_Channel &ch = getByIndex(chIndex); + initDefaultChannel(channelFile, chIndex); +} + +void Channels::initDefaultChannel(meshtastic_ChannelFile &file, ChannelIndex chIndex) +{ + meshtastic_Channel &ch = file.channels[chIndex]; meshtastic_ChannelSettings &channelSettings = ch.settings; uint8_t defaultpskIndex = 1; @@ -382,6 +390,51 @@ void Channels::setChannel(const meshtastic_Channel &c) old = c; // slam in the new settings/role } +ChannelIndex Channels::setChannelInFile(meshtastic_ChannelFile &file, const meshtastic_Channel &channel, + ChannelIndex fallbackPrimary, bool ensurePrimary) +{ + if (file.channels_count == 0 || file.channels_count > MAX_NUM_CHANNELS) + file.channels_count = MAX_NUM_CHANNELS; + if (channel.index < 0 || channel.index >= file.channels_count) + return fallbackPrimary < file.channels_count ? fallbackPrimary : 0; + + if (channel.role == meshtastic_Channel_Role_PRIMARY) { + for (pb_size_t i = 0; i < file.channels_count; ++i) { + if (file.channels[i].role == meshtastic_Channel_Role_PRIMARY) + file.channels[i].role = meshtastic_Channel_Role_SECONDARY; + } + } + file.channels[channel.index] = channel; + + ChannelIndex primary = fallbackPrimary < file.channels_count ? fallbackPrimary : 0; + bool hasPrimary = false; + for (pb_size_t i = 0; i < file.channels_count; ++i) { + auto &candidate = file.channels[i]; + candidate.index = i; + if (!candidate.has_settings) { + candidate.role = meshtastic_Channel_Role_DISABLED; + memset(&candidate.settings, 0, sizeof(candidate.settings)); + candidate.has_settings = true; + } else if (strcmp(candidate.settings.name, "Default") == 0) { + candidate.settings.name[0] = '\0'; + } + if (candidate.role == meshtastic_Channel_Role_PRIMARY) { + primary = i; + hasPrimary = true; + } + } + + if (!hasPrimary && ensurePrimary) { + if (file.channels[primary].role == meshtastic_Channel_Role_SECONDARY) { + file.channels[primary].role = meshtastic_Channel_Role_PRIMARY; + } else { + primary = 0; + initDefaultChannel(file, primary); + } + } + return primary; +} + bool Channels::anyMqttEnabled() { #if USERPREFS_EVENT_MODE && !MESHTASTIC_EXCLUDE_MQTT diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index 6e17a7ab618..8700bfd164c 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -47,6 +47,10 @@ class Channels */ void setChannel(const meshtastic_Channel &c); + static ChannelIndex setChannelInFile(meshtastic_ChannelFile &file, const meshtastic_Channel &channel, + ChannelIndex fallbackPrimary, bool ensurePrimary = true); + static bool ensureLicensedOperation(meshtastic_ChannelFile &file, bool licensed); + /** Return a human friendly name for this channel (and expand any short strings as needed) */ const char *getName(size_t chIndex); @@ -138,6 +142,7 @@ class Channels * Write default channels defined in UserPrefs */ void initDefaultChannel(ChannelIndex chIndex); + static void initDefaultChannel(meshtastic_ChannelFile &file, ChannelIndex chIndex); /** * Return the key used for encrypting this channel (if channel is secondary and no key provided, use the primary channel's @@ -164,4 +169,4 @@ bool channelFileUsesPublicKey(const meshtastic_ChannelFile &cf, ChannelIndex chI static const uint8_t eventpsk[] = {0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36, 0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74, - 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1}; \ No newline at end of file + 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1}; diff --git a/src/mesh/LR11x0ConfigApply.h b/src/mesh/LR11x0ConfigApply.h new file mode 100644 index 00000000000..59de259d417 --- /dev/null +++ b/src/mesh/LR11x0ConfigApply.h @@ -0,0 +1,140 @@ +#pragma once + +#include "MeshRadio.h" + +#include + +enum class LR11x0ApplyStep : uint8_t { + STANDBY, + SPREADING_FACTOR, + BANDWIDTH, + CODING_RATE, + SYNC_WORD, + PREAMBLE, + FREQUENCY, + OUTPUT_POWER, + RX_GAIN, + START_RECEIVE, + COUNT, +}; + +struct LR11x0ConfigApplyParams { + uint8_t spreadingFactor; + float bandwidth; + uint8_t codingRate; + uint8_t syncWord; + uint16_t preambleLength; + float frequency; + int8_t outputPower; + bool boostedGain; + bool wideBand; +}; + +struct LR11x0BandPolicy { + bool wideBand; + int8_t maxPower; +}; + +inline LR11x0BandPolicy lr11x0BandPolicyFor(const RegionInfo ®ion, bool supportsWideBand, int8_t subGhzMaxPower, + int8_t wideBandMaxPower) +{ + const bool wideBand = region.wideLora && supportsWideBand; + return {wideBand, wideBand ? wideBandMaxPower : subGhzMaxPower}; +} + +inline LR11x0ConfigApplyParams makeLR11x0ConfigApplyParams(uint8_t spreadingFactor, float bandwidth, uint8_t codingRate, + uint8_t syncWord, uint16_t preambleLength, float frequency, + int8_t outputPower, bool boostedGain, + const LR11x0BandPolicy &bandPolicy) +{ + return {spreadingFactor, bandwidth, codingRate, syncWord, preambleLength, + frequency, outputPower, boostedGain, bandPolicy.wideBand}; +} + +template int lr11x0BeginForBand(Ops &ops, const LR11x0ConfigApplyParams ¶ms) +{ + return ops.beginLoRa(params.bandwidth, params.spreadingFactor, params.codingRate, params.syncWord, params.preambleLength, + params.wideBand); +} + +template int lr11x0SetFrequencyForBand(Ops &ops, float frequency, bool targetWideBand, bool &configuredWideBand) +{ + if (configuredWideBand == targetWideBand) + // Wide-band operation skips the sub-GHz image calibration performed by RadioLib. + return ops.setFrequency(frequency, targetWideBand); + + int frequencyResult = ops.setFrequency(frequency, true); + if (ops.isRetryableFrequencyError(frequencyResult)) { + ops.waitForFrequencyRetry(); + frequencyResult = ops.setFrequency(frequency, true); + } + if (frequencyResult != 0) + return frequencyResult; + + configuredWideBand = targetWideBand; + return targetWideBand ? 0 : ops.calibrateImage(frequency - 4.0f, frequency + 4.0f); +} + +inline const char *lr11x0ApplyStepName(LR11x0ApplyStep step) +{ + static const char *const names[] = {"standby", "spreading factor", "bandwidth", "coding rate", "sync word", + "preamble", "frequency", "output power", "RX gain", "start receive"}; + return step < LR11x0ApplyStep::COUNT ? names[static_cast(step)] : "unknown"; +} + +template class LR11x0ConfigApply +{ + public: + static int run(Ops &ops, const LR11x0ConfigApplyParams ¶ms, LR11x0ApplyStep *failedStep = nullptr) + { + int error = ops.standby(); + if (error != 0) + return fail(failedStep, LR11x0ApplyStep::STANDBY, error); + + error = ops.setSpreadingFactor(params.spreadingFactor); + if (error != 0) + return fail(failedStep, LR11x0ApplyStep::SPREADING_FACTOR, error); + + error = ops.setBandwidth(params.bandwidth, params.wideBand); + if (error != 0) + return fail(failedStep, LR11x0ApplyStep::BANDWIDTH, error); + + error = ops.setCodingRate(params.codingRate, params.codingRate != 7); + if (error != 0) + return fail(failedStep, LR11x0ApplyStep::CODING_RATE, error); + + error = ops.setSyncWord(params.syncWord); + if (error != 0) + return fail(failedStep, LR11x0ApplyStep::SYNC_WORD, error); + + error = ops.setPreambleLength(params.preambleLength); + if (error != 0) + return fail(failedStep, LR11x0ApplyStep::PREAMBLE, error); + + error = ops.setFrequency(params.frequency); + if (error != 0) + return fail(failedStep, LR11x0ApplyStep::FREQUENCY, error); + + error = ops.setOutputPower(params.outputPower); + if (error != 0) + return fail(failedStep, LR11x0ApplyStep::OUTPUT_POWER, error); + + error = ops.setRxBoostedGainMode(params.boostedGain); + if (error != 0) + return fail(failedStep, LR11x0ApplyStep::RX_GAIN, error); + + error = ops.startReceive(); + if (error != 0) + return fail(failedStep, LR11x0ApplyStep::START_RECEIVE, error); + + return error; + } + + private: + static int fail(LR11x0ApplyStep *failedStep, LR11x0ApplyStep step, int error) + { + if (failedStep != nullptr) + *failedStep = step; + return error; + } +}; diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index c54ae91a6b1..ad46adf41cd 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -44,6 +44,13 @@ LR11x0Interface::LR11x0Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs LOG_WARN("LR11x0Interface(cs=%d, irq=%d, rst=%d, busy=%d)", cs, irq, rst, busy); } +template bool LR11x0Interface::supportsLoRaBandwidth(float bandwidthKHz, bool wideBand) +{ + if (wideBand) + return bandwidthKHz == 203.125f || bandwidthKHz == 406.25f || bandwidthKHz == 812.5f; + return bandwidthKHz == 62.5f || bandwidthKHz == 125.0f || bandwidthKHz == 250.0f || bandwidthKHz == 500.0f; +} + /// Initialise the Driver transport hardware and software. /// Make sure the Driver is properly configured before calling init(). /// \return true if initialisation succeeded. @@ -72,7 +79,8 @@ template bool LR11x0Interface::init() LOG_DEBUG("LR11X0_DIO3_TCXO_VOLTAGE not defined, not using DIO3 as TCXO reference voltage"); #endif - RadioLibInterface::init(); + if (!RadioLibInterface::init()) + return false; if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range limitPower(LR1120_MAX_POWER); @@ -82,15 +90,13 @@ template bool LR11x0Interface::init() #ifdef LR11X0_RF_SWITCH_SUBGHZ pinMode(LR11X0_RF_SWITCH_SUBGHZ, OUTPUT); - digitalWrite(LR11X0_RF_SWITCH_SUBGHZ, getFreq() < 1e9 ? HIGH : LOW); - LOG_DEBUG("Set RF0 switch to %s", getFreq() < 1e9 ? "SubGHz" : "2.4GHz"); #endif #ifdef LR11X0_RF_SWITCH_2_4GHZ pinMode(LR11X0_RF_SWITCH_2_4GHZ, OUTPUT); - digitalWrite(LR11X0_RF_SWITCH_2_4GHZ, getFreq() < 1e9 ? LOW : HIGH); - LOG_DEBUG("Set RF1 switch to %s", getFreq() < 1e9 ? "SubGHz" : "2.4GHz"); #endif + selectExternalRfPath(getFreq()); + LOG_DEBUG("Set external RF path to %s", getFreq() < 1000.0f ? "SubGHz" : "2.4GHz"); // Allow extra time for TCXO to stabilize after power-on delay(10); @@ -117,7 +123,7 @@ template bool LR11x0Interface::init() // \todo Display actual typename of the adapter, not just `LR11x0` LOG_INFO("LR11x0 init result %d", res); - if (res == RADIOLIB_ERR_CHIP_NOT_FOUND || res == RADIOLIB_ERR_SPI_CMD_FAILED) + if (res != RADIOLIB_ERR_NONE) return false; LR11x0VersionInfo_t version; @@ -163,56 +169,110 @@ template bool LR11x0Interface::init() if (res == RADIOLIB_ERR_NONE) startReceive(); // start receiving + if (res == RADIOLIB_ERR_NONE) + configuredWideBand = getRegion(config.lora.region)->wideLora; + return res == RADIOLIB_ERR_NONE; } template bool LR11x0Interface::reconfigure() { - RadioLibInterface::reconfigure(); + bool baseSuccess = true; + const LR11x0ConfigApplyParams params = makeReconfigureParams(&baseSuccess); + ConfigApplyOps ops(*this); + LR11x0ApplyStep failedStep = LR11x0ApplyStep::COUNT; + int error = LR11x0ConfigApply::run(ops, params, &failedStep); + if (error != RADIOLIB_ERR_NONE) { + LOG_WARN("LR11x0 live reconfigure %s %s%d; reinitializing radio", lr11x0ApplyStepName(failedStep), radioLibErr, error); + error = reinitializeForBand(params, &failedStep); + if (error != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 reinitialize %s %s%d", lr11x0ApplyStepName(failedStep), radioLibErr, error); + if (shouldRecordReconfigureFailure()) + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + return false; + } + } - // set mode to standby - setStandby(); + finishStartReceive(); + return baseSuccess; +} - // configure publicly accessible settings - int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); +template +int LR11x0Interface::reinitializeForBand(const LR11x0ConfigApplyParams ¶ms, LR11x0ApplyStep *failedStep) +{ + auto fail = [failedStep](LR11x0ApplyStep step, int error) { + if (failedStep) + *failedStep = step; + return error; + }; + + ConfigApplyOps ops(*this); + int error = lr11x0BeginForBand(ops, params); + if (error != RADIOLIB_ERR_NONE) + return fail(LR11x0ApplyStep::STANDBY, error); + + error = lora.setCodingRate(params.codingRate, params.codingRate != 7); + if (error != RADIOLIB_ERR_NONE) + return fail(LR11x0ApplyStep::CODING_RATE, error); + + error = lora.setFrequency(params.frequency, true); + if (error != RADIOLIB_ERR_NONE) + return fail(LR11x0ApplyStep::FREQUENCY, error); + configuredWideBand = params.wideBand; + + if (!params.wideBand) { + error = lora.calibrateImageRejection(params.frequency - 4.0f, params.frequency + 4.0f); + if (error != RADIOLIB_ERR_NONE) + return fail(LR11x0ApplyStep::FREQUENCY, error); + } - err = lora.setBandwidth(bw, wideLora() && (getFreq() > 1000.0f)); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + error = lora.setOutputPower(params.outputPower); + if (error != RADIOLIB_ERR_NONE) + return fail(LR11x0ApplyStep::OUTPUT_POWER, error); - err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + error = lora.setRegulatorDCDC(); + if (error != RADIOLIB_ERR_NONE) + return fail(LR11x0ApplyStep::OUTPUT_POWER, error); - err = lora.setSyncWord(syncWord); - assert(err == RADIOLIB_ERR_NONE); - - if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range - limitPower(LR1120_MAX_POWER); - } else { - limitPower(LR1110_MAX_POWER); // default clamp for non-wide freq range - } +#ifdef LR11X0_DIO_AS_RF_SWITCH + lora.setRfSwitchTable(rfswitch_dio_pins, rfswitch_table); +#elif ARCH_PORTDUINO + if (portduino_config.has_rfswitch_table) + lora.setRfSwitchTable(rfswitch_dio_pins, rfswitch_table); +#endif - err = lora.setPreambleLength(preambleLength); - assert(err == RADIOLIB_ERR_NONE); + error = lora.setRxBoostedGainMode(params.boostedGain); + if (error != RADIOLIB_ERR_NONE) + return fail(LR11x0ApplyStep::RX_GAIN, error); - err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + selectExternalRfPath(params.frequency); + error = lora.startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + return error == RADIOLIB_ERR_NONE ? error : fail(LR11x0ApplyStep::START_RECEIVE, error); +} - err = lora.setOutputPower(power); - assert(err == RADIOLIB_ERR_NONE); +template void LR11x0Interface::selectExternalRfPath(float frequency) +{ +#ifdef LR11X0_RF_SWITCH_SUBGHZ + digitalWrite(LR11X0_RF_SWITCH_SUBGHZ, frequency < 1000.0f ? HIGH : LOW); +#endif +#ifdef LR11X0_RF_SWITCH_2_4GHZ + digitalWrite(LR11X0_RF_SWITCH_2_4GHZ, frequency < 1000.0f ? LOW : HIGH); +#endif +} - // Apply RX gain mode - valid in STDBY, matches resetAGC() pattern - err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); - if (err != RADIOLIB_ERR_NONE) - LOG_WARN("LR11x0 setRxBoostedGainMode %s%d", radioLibErr, err); +template LR11x0ConfigApplyParams LR11x0Interface::makeReconfigureParams(bool *baseSuccess) +{ + const bool success = RadioLibInterface::reconfigure(); + if (baseSuccess) + *baseSuccess = success; + const meshtastic_Config_LoRaConfig &loraConfig = getActiveLoRaConfig(); - startReceive(); // restart receiving + const LR11x0BandPolicy bandPolicy = + lr11x0BandPolicyFor(*getRegion(loraConfig.region), wideLora(), LR1110_MAX_POWER, LR1120_MAX_POWER); + limitPower(bandPolicy.maxPower); - return true; + return makeLR11x0ConfigApplyParams(sf, bw, cr, syncWord, preambleLength, getFreq(), power, loraConfig.sx126x_rx_boosted_gain, + bandPolicy); } template void LR11x0Interface::disableInterrupt() @@ -220,23 +280,36 @@ template void LR11x0Interface::disableInterrupt() lora.clearIrqAction(); } -template void LR11x0Interface::setStandby() +template int LR11x0Interface::setStandby(bool completePacket) { checkNotification(); // handle any pending interrupts before we force standby - int err = lora.standby(); + const int err = lora.standby(); if (err != RADIOLIB_ERR_NONE) { LOG_DEBUG("LR11x0 standby failed with error %d", err); + return err; } - assert(err == RADIOLIB_ERR_NONE); - isReceiving = false; // If we were receiving, not any more activeReceiveStart = 0; disableInterrupt(); - completeSending(); // If we were sending, not anymore + if (completePacket) + completeSending(); RadioLibInterface::setStandby(); + return err; +} + +template int LR11x0Interface::setStandbyForReconfigure() +{ + return setStandby(false); +} + +template void LR11x0Interface::setStandby() +{ + const int err = setStandby(true); + assert(err == RADIOLIB_ERR_NONE); + (void)err; } /** @@ -266,7 +339,6 @@ template void LR11x0Interface::startReceive() #ifdef SLEEP_ONLY sleep(); #else - setStandby(); lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. Not sure why this is needed. @@ -278,12 +350,15 @@ template void LR11x0Interface::startReceive() LOG_ERROR("StartReceive error: %d", err); assert(err == RADIOLIB_ERR_NONE); - RadioLibInterface::startReceive(); + finishStartReceive(); +#endif +} - // Must be done AFTER, starting transmit, because startTransmit clears (possibly stale) interrupt pending register bits +template void LR11x0Interface::finishStartReceive() +{ + RadioLibInterface::startReceive(); enableInterrupt(isrRxLevel0); checkRxDoneIrqFlag(); -#endif } /** Is the channel currently active? */ diff --git a/src/mesh/LR11x0Interface.h b/src/mesh/LR11x0Interface.h index ee8761177da..05260516e9a 100644 --- a/src/mesh/LR11x0Interface.h +++ b/src/mesh/LR11x0Interface.h @@ -1,5 +1,6 @@ #pragma once #if RADIOLIB_EXCLUDE_LR11X0 != 1 +#include "LR11x0ConfigApply.h" #include "RadioLibInterface.h" /** @@ -17,6 +18,8 @@ template class LR11x0Interface : public RadioLibInterface /// \return true if initialisation succeeded. virtual bool init() override; + bool supportsLoRaBandwidth(float bandwidthKHz, bool wideBand) override; + /// Apply any radio provisioning changes /// Make sure the Driver is properly configured before calling init(). /// \return true if initialisation succeeded. @@ -73,5 +76,62 @@ template class LR11x0Interface : public RadioLibInterface virtual void setStandby() override; uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + + LR11x0ConfigApplyParams makeReconfigureParams(bool *baseSuccess = nullptr); + + private: + bool configuredWideBand = false; + + class ConfigApplyOps + { + public: + explicit ConfigApplyOps(LR11x0Interface &radio) : radio(radio) {} + + int beginLoRa(float bandwidth, uint8_t spreadingFactor, uint8_t codingRate, uint8_t syncWord, uint16_t preambleLength, + bool wideBand) + { + return static_cast(radio.lora) + .begin(bandwidth, spreadingFactor, codingRate, syncWord, preambleLength, wideBand); + } + int standby() { return radio.setStandbyForReconfigure(); } + int setSpreadingFactor(uint8_t spreadingFactor) { return radio.lora.setSpreadingFactor(spreadingFactor); } + int setBandwidth(float bandwidth, bool wideBand) + { + targetWideBand = wideBand; + return radio.lora.setBandwidth(bandwidth, wideBand); + } + int setCodingRate(uint8_t codingRate, bool interleaving) { return radio.lora.setCodingRate(codingRate, interleaving); } + int setSyncWord(uint8_t syncWord) { return radio.lora.setSyncWord(syncWord); } + int setPreambleLength(uint16_t preambleLength) { return radio.lora.setPreambleLength(preambleLength); } + int setFrequency(float frequency) + { + return lr11x0SetFrequencyForBand(*this, frequency, targetWideBand, radio.configuredWideBand); + } + int setFrequency(float frequency, bool skipCalibration) { return radio.lora.setFrequency(frequency, skipCalibration); } + int calibrateImage(float frequencyMin, float frequencyMax) + { + return radio.lora.calibrateImageRejection(frequencyMin, frequencyMax); + } + void waitForFrequencyRetry() { delay(100); } + bool isRetryableFrequencyError(int error) { return error == RADIOLIB_ERR_SPI_CMD_FAILED; } + int setOutputPower(int8_t outputPower) { return radio.lora.setOutputPower(outputPower); } + int setRxBoostedGainMode(bool boostedGain) { return radio.lora.setRxBoostedGainMode(boostedGain); } + int startReceive() + { + radio.selectExternalRfPath(radio.getFreq()); + return radio.lora.startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, + RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + } + + private: + LR11x0Interface &radio; + bool targetWideBand = false; + }; + + int setStandbyForReconfigure(); + int setStandby(bool completePacket); + int reinitializeForBand(const LR11x0ConfigApplyParams ¶ms, LR11x0ApplyStep *failedStep); + void selectExternalRfPath(float frequency); + void finishStartReceive(); }; -#endif \ No newline at end of file +#endif diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index c7d00e3ab31..d33ea871cff 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -50,6 +50,14 @@ LR20x0Interface::LR20x0Interface(LockingArduinoHal *hal, RADIOLIB_PIN_TYPE cs LOG_WARN("LR20x0Interface(cs=%d, irq=%d, rst=%d, busy=%d)", cs, irq, rst, busy); } +template bool LR20x0Interface::supportsLoRaBandwidth(float bandwidthKHz, bool wideBand) +{ + (void)wideBand; + return bandwidthKHz == 31.25f || bandwidthKHz == 41.7f || bandwidthKHz == 62.5f || bandwidthKHz == 83.0f || + bandwidthKHz == 101.0f || bandwidthKHz == 125.0f || bandwidthKHz == 203.125f || bandwidthKHz == 250.0f || + bandwidthKHz == 406.25f || bandwidthKHz == 500.0f || bandwidthKHz == 812.5f || bandwidthKHz == 1000.0f; +} + /// Initialise the Driver transport hardware and software. /// Make sure the Driver is properly configured before calling init(). /// \return true if initialisation succeeded. @@ -78,7 +86,8 @@ template bool LR20x0Interface::init() LOG_DEBUG("LR2021_DIO3_TCXO_VOLTAGE not defined, not using DIO3 as TCXO reference voltage"); #endif - RadioLibInterface::init(); + if (!RadioLibInterface::init()) + return false; #ifdef LR2021_IRQ_DIO_NUM lora.irqDioNum = LR2021_IRQ_DIO_NUM; @@ -98,15 +107,13 @@ template bool LR20x0Interface::init() #ifdef LR2021_RF_SWITCH_SUBGHZ pinMode(LR2021_RF_SWITCH_SUBGHZ, OUTPUT); - digitalWrite(LR2021_RF_SWITCH_SUBGHZ, getFreq() < 1e9 ? HIGH : LOW); - LOG_DEBUG("Set RF0 switch to %s", getFreq() < 1e9 ? "SubGHz" : "2.4GHz"); #endif #ifdef LR2021_RF_SWITCH_2_4GHZ pinMode(LR2021_RF_SWITCH_2_4GHZ, OUTPUT); - digitalWrite(LR2021_RF_SWITCH_2_4GHZ, getFreq() < 1e9 ? LOW : HIGH); - LOG_DEBUG("Set RF1 switch to %s", getFreq() < 1e9 ? "SubGHz" : "2.4GHz"); #endif + selectExternalRfPath(getFreq()); + LOG_DEBUG("Set external RF path to %s", getFreq() < 1000.0f ? "SubGHz" : "2.4GHz"); // Allow extra time for TCXO to stabilize after power-on delay(10); @@ -174,51 +181,59 @@ template bool LR20x0Interface::init() template bool LR20x0Interface::reconfigure() { - RadioLibInterface::reconfigure(); + bool success = RadioLibInterface::reconfigure(); + const meshtastic_Config_LoRaConfig &loraConfig = getActiveLoRaConfig(); - // set mode to standby - setStandby(); + const auto recordConfigError = [this, &success](const char *operation, int result) { + if (result == RADIOLIB_ERR_NONE) + return; + LOG_ERROR("LR20x0 %s %s%d", operation, radioLibErr, result); + if (shouldRecordReconfigureFailure()) + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + success = false; + }; + + int err = setStandby(false); + recordConfigError("standby", err); // configure publicly accessible settings - int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + err = lora.setSpreadingFactor(sf); + recordConfigError("setSpreadingFactor", err); err = lora.setBandwidth(bw); // different form than LR11xx - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + recordConfigError("setBandwidth", err); err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + recordConfigError("setCodingRate", err); err = lora.setSyncWord(syncWord); - assert(err == RADIOLIB_ERR_NONE); + recordConfigError("setSyncWord", err); - if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range + if (loraConfig.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range limitPower(LR2021_MAX_POWER_HF); } else { limitPower(LR2021_MAX_POWER); // default clamp for non-wide freq range } err = lora.setPreambleLength(preambleLength); - assert(err == RADIOLIB_ERR_NONE); + recordConfigError("setPreambleLength", err); err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + recordConfigError("setFrequency", err); err = lora.setOutputPower(power); - assert(err == RADIOLIB_ERR_NONE); + recordConfigError("setOutputPower", err); // Apply RX gain mode - valid in STDBY, matches resetAGC() pattern - err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); - if (err != RADIOLIB_ERR_NONE) - LOG_WARN("LR20x0 setRxBoostedGainMode %s%d", radioLibErr, err); + err = lora.setRxBoostedGainMode(loraConfig.sx126x_rx_boosted_gain); + recordConfigError("setRxBoostedGainMode", err); - startReceive(); // restart receiving + if (success) { + err = startReceiveForReconfigure(); + recordConfigError("startReceive", err); + } - return true; + return success; } template void LR20x0Interface::disableInterrupt() @@ -226,7 +241,7 @@ template void LR20x0Interface::disableInterrupt() lora.clearIrqAction(); } -template void LR20x0Interface::setStandby() +template int LR20x0Interface::setStandby(bool completePacket) { checkNotification(); // handle any pending interrupts before we force standby @@ -236,13 +251,23 @@ template void LR20x0Interface::setStandby() LOG_DEBUG("LR20x0 standby failed with error %d", err); } - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) + return err; isReceiving = false; // If we were receiving, not any more activeReceiveStart = 0; disableInterrupt(); - completeSending(); // If we were sending, not anymore + if (completePacket) + completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +template void LR20x0Interface::setStandby() +{ + const int err = setStandby(true); + assert(err == RADIOLIB_ERR_NONE); + (void)err; } /** @@ -273,23 +298,46 @@ template void LR20x0Interface::startReceive() #ifdef SLEEP_ONLY sleep(); #else - setStandby(); - lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. Not sure why this is needed. - - // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. - int err = - lora.startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); - if (err) + const int err = startReceiveForReconfigure(); + if (err != RADIOLIB_ERR_NONE) LOG_ERROR("StartReceive error: %d", err); assert(err == RADIOLIB_ERR_NONE); +#endif +} + +template int LR20x0Interface::startReceiveForReconfigure() +{ +#ifdef SLEEP_ONLY + sleep(); + return RADIOLIB_ERR_NONE; +#else + int err = lora.setPreambleLength(preambleLength); // Solve RX ack fail after direct message sent. + if (err != RADIOLIB_ERR_NONE) + return err; + + selectExternalRfPath(getFreq()); + err = lora.startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS, RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + if (err != RADIOLIB_ERR_NONE) + return err; RadioLibInterface::startReceive(); // Must be done AFTER starting receive, because startReceive clears (possibly stale) interrupt pending register bits enableInterrupt(isrRxLevel0); checkRxDoneIrqFlag(); + return RADIOLIB_ERR_NONE; +#endif +} + +template void LR20x0Interface::selectExternalRfPath(float frequency) +{ +#ifdef LR2021_RF_SWITCH_SUBGHZ + digitalWrite(LR2021_RF_SWITCH_SUBGHZ, frequency < 1000.0f ? HIGH : LOW); +#endif +#ifdef LR2021_RF_SWITCH_2_4GHZ + digitalWrite(LR2021_RF_SWITCH_2_4GHZ, frequency < 1000.0f ? LOW : HIGH); #endif } diff --git a/src/mesh/LR20x0Interface.h b/src/mesh/LR20x0Interface.h index 263c83429d2..c80e7adbe19 100644 --- a/src/mesh/LR20x0Interface.h +++ b/src/mesh/LR20x0Interface.h @@ -22,6 +22,8 @@ template class LR20x0Interface : public RadioLibInterface /// \return true if initialisation succeeded. virtual bool reconfigure() override; + bool supportsLoRaBandwidth(float bandwidthKHz, bool wideBand) override; + /// Prepare hardware for sleep. Call this _only_ for deep sleep, not needed for light sleep. virtual bool sleep() override; @@ -71,6 +73,9 @@ template class LR20x0Interface : public RadioLibInterface virtual void addReceiveMetadata(meshtastic_MeshPacket *mp) override; virtual void setStandby() override; + int setStandby(bool completePacket); + int startReceiveForReconfigure(); + void selectExternalRfPath(float frequency); uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } }; diff --git a/src/mesh/MeshPacketQueue.cpp b/src/mesh/MeshPacketQueue.cpp index 4aad40c69d0..13ccd20bac6 100644 --- a/src/mesh/MeshPacketQueue.cpp +++ b/src/mesh/MeshPacketQueue.cpp @@ -65,11 +65,20 @@ void fixPriority(meshtastic_MeshPacket *p) } /** enqueue a packet, return false if full */ -bool MeshPacketQueue::enqueue(meshtastic_MeshPacket *p, bool *dropped) +bool MeshPacketQueue::enqueue(meshtastic_MeshPacket *p, bool *dropped, meshtastic_MeshPacket **evicted) { + if (evicted) + *evicted = nullptr; // no space - try to replace a lower priority packet in the queue if (queue.size() >= maxLen) { - bool replaced = replaceLowerPriorityPacket(p); + meshtastic_MeshPacket *replacedPacket = nullptr; + bool replaced = replaceLowerPriorityPacket(p, &replacedPacket); + if (replacedPacket) { + if (evicted) + *evicted = replacedPacket; + else + packetPool.release(replacedPacket); + } if (!replaced) { LOG_WARN("TX queue is full, and there is no lower-priority packet available to evict in favour of 0x%08x", p->id); } @@ -148,8 +157,9 @@ bool MeshPacketQueue::find(const NodeNum from, const PacketId id) * Attempt to find a lower-priority packet in the queue and replace it with the provided one. * @return True if the replacement succeeded, false otherwise */ -bool MeshPacketQueue::replaceLowerPriorityPacket(meshtastic_MeshPacket *p) +bool MeshPacketQueue::replaceLowerPriorityPacket(meshtastic_MeshPacket *p, meshtastic_MeshPacket **evicted) { + assert(evicted != nullptr); if (queue.empty()) { return false; // No packets to replace @@ -161,7 +171,7 @@ bool MeshPacketQueue::replaceLowerPriorityPacket(meshtastic_MeshPacket *p) LOG_WARN("Dropping packet 0x%08x to make room in the TX queue for higher-priority packet 0x%08x", backPacket->id, p->id); // Remove the back packet queue.pop_back(); - packetPool.release(backPacket); + *evicted = backPacket; // Insert the new packet in the correct order enqueue(p); return true; @@ -177,7 +187,7 @@ bool MeshPacketQueue::replaceLowerPriorityPacket(meshtastic_MeshPacket *p) LOG_WARN("Dropping non-late packet 0x%08x to make room in the TX queue for higher-priority packet 0x%08x", refPacket->id, p->id); queue.erase(it); - packetPool.release(refPacket); + *evicted = refPacket; // Insert the new packet in the correct order enqueue(p); return true; @@ -200,7 +210,7 @@ bool MeshPacketQueue::replaceLowerPriorityPacket(meshtastic_MeshPacket *p) backPacket->id, dt, p->id); } queue.pop_back(); - packetPool.release(backPacket); + *evicted = backPacket; // Insert the new packet in the correct order enqueue(p); return true; @@ -209,4 +219,4 @@ bool MeshPacketQueue::replaceLowerPriorityPacket(meshtastic_MeshPacket *p) // If the back packet's priority is not lower, no replacement occurs return false; -} \ No newline at end of file +} diff --git a/src/mesh/MeshPacketQueue.h b/src/mesh/MeshPacketQueue.h index 3d3902c1ea6..b4237dd9ece 100644 --- a/src/mesh/MeshPacketQueue.h +++ b/src/mesh/MeshPacketQueue.h @@ -14,15 +14,16 @@ class MeshPacketQueue /** Replace a lower priority package in the queue with 'mp' (provided there are lower pri packages). Return true if replaced. */ - bool replaceLowerPriorityPacket(meshtastic_MeshPacket *mp); + bool replaceLowerPriorityPacket(meshtastic_MeshPacket *mp, meshtastic_MeshPacket **evicted); public: explicit MeshPacketQueue(size_t _maxLen); /** enqueue a packet, return false if full * @param dropped Optional pointer to a bool that will be set to true if a packet was dropped + * @param evicted Optional displaced packet output; the caller owns and must release it */ - bool enqueue(meshtastic_MeshPacket *p, bool *dropped = nullptr); + bool enqueue(meshtastic_MeshPacket *p, bool *dropped = nullptr, meshtastic_MeshPacket **evicted = nullptr); /** return true if the queue is empty */ bool empty(); @@ -46,4 +47,4 @@ class MeshPacketQueue /* Attempt to find a packet from this queue. Return true if it was found. */ bool find(const NodeNum from, const PacketId id); -}; \ No newline at end of file +}; diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 2f3dbb1a5fc..fc07ea76ec6 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -120,9 +120,96 @@ int MeshService::handleFromRadio(const meshtastic_MeshPacket *mp) return 0; } +bool MeshService::requestLoRaConfig(const meshtastic_Config_LoRaConfig &previous, const meshtastic_Config_LoRaConfig &candidate, + uint32_t timeoutMsec, bool previousLicensed, bool candidateLicensed, + const meshtastic_ChannelSettings *previousPrimary, + const meshtastic_ChannelSettings *candidatePrimary) +{ + LoRaConfigApplyState expected = LoRaConfigApplyState::IDLE; + if (!loRaConfigApplyState.compare_exchange_strong(expected, LoRaConfigApplyState::PREPARING, std::memory_order_acq_rel, + std::memory_order_acquire)) + return false; + + if (!router) { + loRaConfigApplyState.store(LoRaConfigApplyState::IDLE, std::memory_order_release); + return false; + } + + RadioInterface *radio = router->getRadioIface(); + if (!radio) { + loRaConfigApplyState.store(LoRaConfigApplyState::IDLE, std::memory_order_release); + return false; + } + + RadioConfigApplyRequest &request = loRaConfigApplyRequest; + request.previous = previous; + request.candidate = candidate; + request.requestedAtMsec = millis(); + request.timeoutMsec = timeoutMsec; + request.previousLicensed = previousLicensed; + request.candidateLicensed = candidateLicensed; + request.acceptedRadioId = 0; + request.hasPrimarySnapshots = previousPrimary != nullptr && candidatePrimary != nullptr; + if (request.hasPrimarySnapshots) { + request.previousPrimary = *previousPrimary; + request.candidatePrimary = *candidatePrimary; + } + request.result.store(RadioConfigApplyResult::IDLE, std::memory_order_relaxed); + + if (!radio->requestConfigApply(&request)) { + loRaConfigApplyState.store(LoRaConfigApplyState::IDLE, std::memory_order_release); + return false; + } + + loRaConfigApplyState.store(LoRaConfigApplyState::PENDING, std::memory_order_release); + return true; +} + +RadioConfigApplyResult MeshService::pollLoRaConfigApply() const +{ + const LoRaConfigApplyState state = loRaConfigApplyState.load(std::memory_order_acquire); + if (state == LoRaConfigApplyState::IDLE) + return RadioConfigApplyResult::IDLE; + if (state == LoRaConfigApplyState::PREPARING) + return RadioConfigApplyResult::PENDING; + + const RadioConfigApplyRequest &request = loRaConfigApplyRequest; + const RadioInterface *radio = router ? router->getRadioIface() : nullptr; + if (!radio || !radio->ownsConfigApplyRequest(request)) + return RadioConfigApplyResult::INTERFACE_REPLACED; + return radio->pollConfigApply(request); +} + /// Do idle processing (mostly processing messages which have been queued from the radio) void MeshService::loop() { + LoRaConfigApplyState state = loRaConfigApplyState.load(std::memory_order_acquire); + if (state == LoRaConfigApplyState::PENDING) { + const RadioConfigApplyResult result = pollLoRaConfigApply(); + if (result != RadioConfigApplyResult::IDLE && result != RadioConfigApplyResult::PENDING) { + LoRaConfigApplyState expected = LoRaConfigApplyState::PENDING; + if (loRaConfigApplyState.compare_exchange_strong(expected, LoRaConfigApplyState::FINALIZING, + std::memory_order_acq_rel, std::memory_order_acquire)) { + RadioConfigApplyRequest &request = loRaConfigApplyRequest; + if (result == RadioConfigApplyResult::INTERFACE_REPLACED) + request.result.store(result, std::memory_order_release); + if (adminModule) + adminModule->completeLoRaConfigApply(request); + state = LoRaConfigApplyState::FINALIZING; + } + } + } + + if (state == LoRaConfigApplyState::FINALIZING) { + RadioConfigApplyRequest &request = loRaConfigApplyRequest; + RadioInterface *radio = router ? router->getRadioIface() : nullptr; + if (!radio || !radio->ownsConfigApplyRequest(request) || radio->finalizeConfigApply(&request)) { + loRaConfigApplyState.store(LoRaConfigApplyState::IDLE, std::memory_order_release); + if (adminModule) + adminModule->finalizeLoRaConfigApply(); + } + } + if (lastQueueStatus.free == 0) { // check if there is now free space in TX queue meshtastic_QueueStatus qs = router->getQueueStatus(); if (qs.free != lastQueueStatus.free) diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index bae955969e5..302fca324f4 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -2,6 +2,7 @@ #include #include +#include #include #include "GPSStatus.h" @@ -9,6 +10,7 @@ #include "MeshRadio.h" #include "MeshTypes.h" #include "Observer.h" +#include "RadioConfigApply.h" #ifdef ARCH_PORTDUINO #include "PointerQueue.h" #else @@ -78,6 +80,10 @@ class MeshService /// Updated in loop() to detect when fromNum changes uint32_t oldFromNum = 0; + enum class LoRaConfigApplyState : uint8_t { IDLE, PREPARING, PENDING, FINALIZING }; + RadioConfigApplyRequest loRaConfigApplyRequest{}; + std::atomic loRaConfigApplyState{LoRaConfigApplyState::IDLE}; + public: enum APIState { STATE_DISCONNECTED, // Initial state, no API is connected @@ -174,8 +180,18 @@ class MeshService */ void reloadConfig(int saveWhat = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS); + bool requestLoRaConfig(const meshtastic_Config_LoRaConfig &previous, const meshtastic_Config_LoRaConfig &candidate, + uint32_t timeoutMsec, bool previousLicensed, bool candidateLicensed, + const meshtastic_ChannelSettings *previousPrimary = nullptr, + const meshtastic_ChannelSettings *candidatePrimary = nullptr); + RadioConfigApplyResult pollLoRaConfigApply() const; + bool loRaConfigApplyActive() const + { + return loRaConfigApplyState.load(std::memory_order_acquire) != LoRaConfigApplyState::IDLE; + } + /// The owner User record just got updated, update our node DB and broadcast the info into the mesh - void reloadOwner(bool shouldSave = true); + virtual void reloadOwner(bool shouldSave = true); /// Called when the user wakes up our GUI, normally sends our latest location to the mesh (if we have it), otherwise at least /// sends our nodeinfo diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 9988b16e6a5..6707dcff61d 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -2795,7 +2795,7 @@ static concurrency::Lock g_reloadFromDiskMutex; * Re-run loadFromDisk() after encrypted storage is unlocked at runtime. * Holds the radio in standby across the file IO + proto decode so the * SX12xx is not mid-RX/TX when config.lora is overwritten, then calls - * reconfigure() to push the now-real settings to the chip. + * reconfigureCommitted() to push the now-real settings to the chip. * * Returns true iff every encrypted file decrypted and decoded cleanly. * On false the caller MUST treat storage as corrupt - see header. @@ -2839,7 +2839,8 @@ bool NodeDB::reloadFromDisk() // Push the now-real config to the radio. if (rIface) { channels.onConfigChanged(); - rIface->reconfigure(); + if (!rIface->reconfigureCommitted()) + LOG_ERROR("NodeDB: radio rejected reloaded LoRa configuration"); } return true; } diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 26a765ea87e..83363e6aae3 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -118,7 +118,8 @@ bool RF95Interface::init() digitalWrite(RF95_POWER_EN, HIGH); #endif - RadioLibInterface::init(); + if (!RadioLibInterface::init()) + return false; #if defined(RADIOMASTER_900_BANDIT_NANO) || defined(RADIOMASTER_900_BANDIT) // DAC and DB values based on dBm using interpolation @@ -208,42 +209,41 @@ void RF95Interface::disableInterrupt() bool RF95Interface::reconfigure() { - RadioLibInterface::reconfigure(); + bool success = RadioLibInterface::reconfigure(); + + const auto recordConfigError = [this, &success](const char *operation, int result) { + if (result == RADIOLIB_ERR_NONE) + return; + LOG_ERROR("RF95 %s %s%d", operation, radioLibErr, result); + if (shouldRecordReconfigureFailure()) + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + success = false; + }; - // set mode to standby - setStandby(); + int err = setStandby(false); + recordConfigError("standby", err); // configure publicly accessible settings - int err = lora->setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + err = lora->setSpreadingFactor(sf); + recordConfigError("setSpreadingFactor", err); err = lora->setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + recordConfigError("setBandwidth", err); err = lora->setCodingRate(cr); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + recordConfigError("setCodingRate", err); err = lora->setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("RF95 setSyncWord %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + recordConfigError("setSyncWord", err); err = lora->setCurrentLimit(currentLimit); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("RF95 setCurrentLimit %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + recordConfigError("setCurrentLimit", err); err = lora->setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("RF95 setPreambleLength %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + recordConfigError("setPreambleLength", err); err = lora->setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + recordConfigError("setFrequency", err); limitPower(RF95_MAX_POWER); @@ -252,12 +252,14 @@ bool RF95Interface::reconfigure() #else err = lora->setOutputPower(power); #endif - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + recordConfigError("setOutputPower", err); - startReceive(); // restart receiving + if (success) { + err = startReceiveForReconfigure(); + recordConfigError("startReceive", err); + } - return true; + return success; } /** @@ -271,17 +273,30 @@ void RF95Interface::addReceiveMetadata(meshtastic_MeshPacket *mp) LOG_DEBUG("Corrected frequency offset: %f", lora->getFrequencyError()); } -void RF95Interface::setStandby() +int RF95Interface::setStandby(bool completePacket) { + checkNotification(); + int err = lora->standby(); if (err != RADIOLIB_ERR_NONE) LOG_ERROR("RF95 standby %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + if (err != RADIOLIB_ERR_NONE) + return err; isReceiving = false; // If we were receiving, not any more + activeReceiveStart = 0; disableInterrupt(); - completeSending(); // If we were sending, not anymore + if (completePacket) + completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +void RF95Interface::setStandby() +{ + const int err = setStandby(true); + assert(err == RADIOLIB_ERR_NONE); + (void)err; } /** We override to turn on transmitter power as needed. @@ -295,18 +310,27 @@ void RF95Interface::configHardwareForSend() void RF95Interface::startReceive() { - setTransmitEnable(false); setStandby(); - int err = lora->startReceive(); + + const int err = startReceiveForReconfigure(); if (err != RADIOLIB_ERR_NONE) LOG_ERROR("RF95 startReceive %s%d", radioLibErr, err); assert(err == RADIOLIB_ERR_NONE); +} + +int RF95Interface::startReceiveForReconfigure() +{ + setTransmitEnable(false); + const int err = lora->startReceive(); + if (err != RADIOLIB_ERR_NONE) + return err; isReceiving = true; // Must be done AFTER, starting receive, because startReceive clears (possibly stale) interrupt pending register bits enableInterrupt(isrRxLevel0); checkRxDoneIrqFlag(); + return RADIOLIB_ERR_NONE; } bool RF95Interface::isChannelActive() @@ -357,4 +381,4 @@ int16_t RF95Interface::getCurrentRSSI() float rssi = lora->getRSSI(false); return (int16_t)round(rssi); } -#endif \ No newline at end of file +#endif diff --git a/src/mesh/RF95Interface.h b/src/mesh/RF95Interface.h index 2226067646e..f7ae3b8a72e 100644 --- a/src/mesh/RF95Interface.h +++ b/src/mesh/RF95Interface.h @@ -70,6 +70,8 @@ class RF95Interface : public RadioLibInterface uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(*lora, pl, received); } private: + int setStandby(bool completePacket); + int startReceiveForReconfigure(); /** Some boards require GPIO control of tx vs rx paths */ void setTransmitEnable(bool txon); }; diff --git a/src/mesh/RadioConfigApply.h b/src/mesh/RadioConfigApply.h new file mode 100644 index 00000000000..05985c8c27b --- /dev/null +++ b/src/mesh/RadioConfigApply.h @@ -0,0 +1,31 @@ +#pragma once + +#include "meshtastic/channel.pb.h" +#include "meshtastic/config.pb.h" +#include +#include + +enum class RadioConfigApplyResult : uint8_t { + IDLE, + PENDING, + APPLIED, + TIMED_OUT, + APPLY_FAILED_ROLLED_BACK, + ROLLBACK_FAILED, + INTERFACE_REPLACED, + BUSY, +}; + +struct RadioConfigApplyRequest { + meshtastic_Config_LoRaConfig previous = meshtastic_Config_LoRaConfig_init_zero; + meshtastic_Config_LoRaConfig candidate = meshtastic_Config_LoRaConfig_init_zero; + uint32_t requestedAtMsec = 0; + uint32_t timeoutMsec = 0; + std::atomic result{RadioConfigApplyResult::IDLE}; + bool previousLicensed = false; + bool candidateLicensed = false; + uint32_t acceptedRadioId = 0; + meshtastic_ChannelSettings previousPrimary = meshtastic_ChannelSettings_init_zero; + meshtastic_ChannelSettings candidatePrimary = meshtastic_ChannelSettings_init_zero; + bool hasPrimarySnapshots = false; +}; diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 208e8603d91..1544e78923b 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -14,6 +14,7 @@ #include "SX1262Interface.h" #include "SX1268Interface.h" #include "SX1280Interface.h" +#include "Throttle.h" #include "configuration.h" #include "detect/LoRaRadioType.h" #include "main.h" @@ -83,6 +84,18 @@ static bool isSwappableEuRegion(meshtastic_Config_LoRaConfig_RegionCode code) return false; } +static const char *getChannelNameForLoRaConfig(const meshtastic_Config_LoRaConfig &loraConfig, + const meshtastic_ChannelSettings *primaryChannel = nullptr) +{ + const meshtastic_ChannelSettings &settings = primaryChannel ? *primaryChannel : channels.getPrimary(); + if (settings.name[0] != '\0') + return settings.name; + + return loraConfig.use_preset + ? DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset) + : "Custom"; +} + // Region profiles: bundle preset list + regulatory parameters shared across regions // presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 1, 1}; @@ -96,6 +109,7 @@ const RegionProfile PROFILE_HAM_20KHZ = {PRESETS_TINY, 0, 0.0022f, false, true, const RegionProfile PROFILE_HAM_100KHZ = {PRESETS_NARROW, 0, 0.01875f, false, true, 0, 1, 1}; Observable RadioInterface::loraRxPacketObservable; +std::atomic RadioInterface::nextConfigApplyOwnerId{1}; #define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr, default_preset, \ override_slot) \ @@ -370,7 +384,7 @@ extern SPIClass SPI1; #endif #endif -std::unique_ptr initLoRa() +std::unique_ptr initLoRa(bool commitBootCorrections) { std::unique_ptr rIf = nullptr; @@ -646,6 +660,8 @@ std::unique_ptr initLoRa() rebootAtMsec = millis() + 5000; } } + if (rIf && commitBootCorrections) + rIf->commitBootConfigCorrection(); return rIf; } @@ -901,17 +917,220 @@ void printPacket(const char *prefix, const meshtastic_MeshPacket *p) #endif } -RadioInterface::RadioInterface() +RadioInterface::RadioInterface() : configApplyOwner(nextConfigApplyOwnerId.fetch_add(1, std::memory_order_relaxed)) { assert(sizeof(PacketHeader) == MESHTASTIC_HEADER_LENGTH); // make sure the compiler did what we expected } bool RadioInterface::reconfigure() { - applyModemConfig(); + return applyModemConfig(); +} + +bool RadioInterface::reconfigureTransient() +{ + return reconfigureConfig(config.lora, devicestate.owner.is_licensed); +} + +bool RadioInterface::reconfigureCommitted() +{ + return reconfigureConfig(config.lora, devicestate.owner.is_licensed, true); +} + +bool RadioInterface::reconfigureConfig(const meshtastic_Config_LoRaConfig &loraConfig, bool licensedOwner, bool recordFailure, + const meshtastic_ChannelSettings *primaryChannel) +{ + const meshtastic_Config_LoRaConfig hardwareConfig = hardwareConfigFor(loraConfig); + configApplyLoraConfig = &hardwareConfig; + configApplyPrimaryChannel = primaryChannel; + configApplyLicensedOwner = licensedOwner; + configApplyLicensedOwnerSet = true; + recordConfigApplyFailure = recordFailure; + const bool result = reconfigure(); + configApplyLoraConfig = nullptr; + configApplyPrimaryChannel = nullptr; + configApplyLicensedOwnerSet = false; + recordConfigApplyFailure = false; + return result; +} + +meshtastic_Config_LoRaConfig RadioInterface::hardwareConfigFor(const meshtastic_Config_LoRaConfig &loraConfig) +{ + meshtastic_Config_LoRaConfig hardwareConfig = loraConfig; + if (hardwareConfig.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET && wideLora() && !supportsSubGhz()) { + hardwareConfig.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + hardwareConfig.use_preset = true; + hardwareConfig.modem_preset = getRegion(hardwareConfig.region)->getDefaultPreset(); + hardwareConfig.channel_num = 0; + hardwareConfig.override_frequency = 0; + hardwareConfig.frequency_offset = 0; + } + return hardwareConfig; +} + +bool RadioInterface::requestConfigApply(RadioConfigApplyRequest *request) +{ + if (request == nullptr) + return false; + const meshtastic_ChannelSettings *candidatePrimary = request->hasPrimarySnapshots ? &request->candidatePrimary : nullptr; + const LoRaConfigNormalization validation = normalizeConfigLora(request->candidate, false, candidatePrimary, this); + if (!validation.valid) { + publishLoRaConfigDiagnostics(validation); + request->result.store(RadioConfigApplyResult::IDLE, std::memory_order_release); + return false; + } + + ConfigApplyPhase expected = ConfigApplyPhase::IDLE; + if (!configApplyPhase.compare_exchange_strong(expected, ConfigApplyPhase::PREPARING, std::memory_order_acq_rel, + std::memory_order_acquire)) { + request->result.store(RadioConfigApplyResult::BUSY, std::memory_order_release); + return false; + } + + request->acceptedRadioId = configApplyOwner; + request->result.store(RadioConfigApplyResult::PENDING, std::memory_order_relaxed); + configApplyRequest.store(request, std::memory_order_relaxed); + configApplyTxGate.fetch_or(CONFIG_APPLY_TX_BARRIER, std::memory_order_acq_rel); + configApplyPhase.store(ConfigApplyPhase::PENDING, std::memory_order_release); + return true; +} + +bool RadioInterface::claimConfigApply(RadioConfigApplyRequest *&request) +{ + ConfigApplyPhase expected = ConfigApplyPhase::PENDING; + if (!configApplyPhase.compare_exchange_strong(expected, ConfigApplyPhase::IN_PROGRESS, std::memory_order_acq_rel, + std::memory_order_acquire)) + return false; + + request = configApplyRequest.load(std::memory_order_acquire); + assert(request != nullptr); return true; } +void RadioInterface::deferConfigApply(const RadioConfigApplyRequest *request) +{ + assert(configApplyRequest.load(std::memory_order_acquire) == request); + ConfigApplyPhase expected = ConfigApplyPhase::IN_PROGRESS; + const bool deferred = configApplyPhase.compare_exchange_strong(expected, ConfigApplyPhase::PENDING, std::memory_order_release, + std::memory_order_relaxed); + assert(deferred); +} + +void RadioInterface::finishConfigApply(RadioConfigApplyRequest *request, RadioConfigApplyResult result) +{ + assert(configApplyRequest.load(std::memory_order_acquire) == request); + ConfigApplyPhase expected = ConfigApplyPhase::IN_PROGRESS; + const bool completed = configApplyPhase.compare_exchange_strong(expected, ConfigApplyPhase::COMPLETE, + std::memory_order_release, std::memory_order_relaxed); + assert(completed); + request->result.store(result, std::memory_order_release); +} + +RadioConfigApplyResult RadioInterface::applyConfigWithRollback(RadioConfigApplyRequest &request) +{ + const meshtastic_ChannelSettings *candidatePrimary = request.hasPrimarySnapshots ? &request.candidatePrimary : nullptr; + const meshtastic_ChannelSettings *previousPrimary = request.hasPrimarySnapshots ? &request.previousPrimary : nullptr; + holdConfigApplyReception(); + if (reconfigureConfig(request.candidate, request.candidateLicensed, false, candidatePrimary)) { + setConfigApplyTxInhibit(false); + return RadioConfigApplyResult::APPLIED; + } + + if (reconfigureConfig(request.previous, request.previousLicensed, false, previousPrimary)) { + setConfigApplyTxInhibit(false); + return RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK; + } + + setConfigApplyTxInhibit(true); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + return RadioConfigApplyResult::ROLLBACK_FAILED; +} + +void RadioInterface::serviceConfigApply(uint32_t nowMsec) +{ + RadioConfigApplyRequest *request = nullptr; + if (!claimConfigApply(request)) + return; + + if (!Throttle::isWithinTimespanMs(request->requestedAtMsec, request->timeoutMsec, nowMsec)) { + finishConfigApply(request, RadioConfigApplyResult::TIMED_OUT); + return; + } + + if (configApplyTxStartActive() || sendingPacket != nullptr || isActivelyReceiving()) { + deferConfigApply(request); + return; + } + + finishConfigApply(request, applyConfigWithRollback(*request)); +} + +RadioConfigApplyResult RadioInterface::pollConfigApply(const RadioConfigApplyRequest &request) const +{ + return request.result.load(std::memory_order_acquire); +} + +bool RadioInterface::finalizeConfigApply(RadioConfigApplyRequest *request) +{ + if (request == nullptr || !ownsConfigApplyRequest(*request) || configApplyRequest.load(std::memory_order_acquire) != request) + return false; + + ConfigApplyPhase expected = ConfigApplyPhase::COMPLETE; + if (!configApplyPhase.compare_exchange_strong(expected, ConfigApplyPhase::FINALIZING, std::memory_order_acq_rel, + std::memory_order_acquire)) + return false; + + configApplyRequest.store(nullptr, std::memory_order_release); + if (request->result.load(std::memory_order_acquire) != RadioConfigApplyResult::ROLLBACK_FAILED) + releaseConfigApplyReception(); + configApplyTxGate.fetch_and(CONFIG_APPLY_TX_CLAIM_MASK, std::memory_order_release); + configApplyPhase.store(ConfigApplyPhase::IDLE, std::memory_order_release); + return true; +} + +void RadioInterface::setConfigApplyTxInhibit(bool inhibited) +{ + configApplyTxInhibit.store(inhibited, std::memory_order_release); +} + +bool RadioInterface::configApplyTxInhibited() const +{ + return configApplyTxInhibit.load(std::memory_order_acquire); +} + +bool RadioInterface::configApplyPending() const +{ + const ConfigApplyPhase phase = configApplyPhase.load(std::memory_order_acquire); + return phase == ConfigApplyPhase::PREPARING || phase == ConfigApplyPhase::PENDING || phase == ConfigApplyPhase::IN_PROGRESS; +} + +bool RadioInterface::configApplyBarrierIsSet() const +{ + return (configApplyTxGate.load(std::memory_order_acquire) & CONFIG_APPLY_TX_BARRIER) != 0; +} + +bool RadioInterface::claimConfigApplyTxStart() +{ + uint32_t gate = configApplyTxGate.load(std::memory_order_acquire); + while ((gate & CONFIG_APPLY_TX_BARRIER) == 0) { + assert((gate & CONFIG_APPLY_TX_CLAIM_MASK) != CONFIG_APPLY_TX_CLAIM_MASK); + if (configApplyTxGate.compare_exchange_weak(gate, gate + 1, std::memory_order_acq_rel, std::memory_order_acquire)) + return true; + } + return false; +} + +void RadioInterface::releaseConfigApplyTxStart() +{ + const uint32_t previous = configApplyTxGate.fetch_sub(1, std::memory_order_release); + assert((previous & CONFIG_APPLY_TX_CLAIM_MASK) != 0); +} + +bool RadioInterface::configApplyTxStartActive() const +{ + return (configApplyTxGate.load(std::memory_order_acquire) & CONFIG_APPLY_TX_CLAIM_MASK) != 0; +} + bool RadioInterface::init() { LOG_INFO("Start meshradio init"); @@ -924,11 +1143,55 @@ bool RadioInterface::init() // radioIf.setThisAddress(nodeDB->getNodeNum()); // Note: we must do this here, because the nodenum isn't inited at // constructor time. - applyModemConfig(); + const bool regionUnset = config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET; + meshtastic_Config_LoRaConfig bootConfig = hardwareConfigFor(config.lora); + LoRaConfigNormalization validation = normalizeConfigLora(bootConfig, false, nullptr, this); + if (!validation.valid) { + LoRaConfigNormalization corrected = normalizeConfigLora(bootConfig, true, nullptr, this); + if (!corrected.valid && wideLora() && !supportsSubGhz()) { + bootConfig.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + bootConfig.use_preset = true; + bootConfig.modem_preset = getRegion(bootConfig.region)->getDefaultPreset(); + bootConfig.channel_num = 0; + bootConfig.override_frequency = 0; + bootConfig.frequency_offset = 0; + corrected = normalizeConfigLora(bootConfig, true, nullptr, this); + } + if (corrected.valid) { + bootConfig = corrected.config; + if (!regionUnset) { + bootConfigCorrection = corrected; + bootConfigCorrectionPending = true; + } + } else { + publishLoRaConfigDiagnostics(corrected); + return false; + } + } + + configApplyLoraConfig = &bootConfig; + const bool applied = applyModemConfig(); + configApplyLoraConfig = nullptr; + if (!applied) + return false; + myRegion = getRegion(bootConfig.region); return true; } +void RadioInterface::commitBootConfigCorrection() +{ + if (!bootConfigCorrectionPending) + return; + + config.lora = bootConfigCorrection.config; + myRegion = getRegion(config.lora.region); + publishLoRaConfigDiagnostics(bootConfigCorrection); + commitLoRaConfigFrequencyFlags(bootConfigCorrection); + nodeDB->saveToDisk(SEGMENT_CONFIG); + bootConfigCorrectionPending = false; +} + int RadioInterface::notifyDeepSleepCb(void *unused) { sleep(); @@ -983,6 +1246,21 @@ uint32_t RadioInterface::getChannelNum() return savedChannelNum; } +const meshtastic_Config_LoRaConfig &RadioInterface::getActiveLoRaConfig() const +{ + return configApplyLoraConfig ? *configApplyLoraConfig : config.lora; +} + +const RegionInfo *RadioInterface::getActiveRegion() const +{ + return configApplyLoraConfig ? getRegion(configApplyLoraConfig->region) : myRegion; +} + +bool RadioInterface::getActiveLicensedOwner() const +{ + return configApplyLicensedOwnerSet ? configApplyLicensedOwner : devicestate.owner.is_licensed; +} + /** * Send a client notification (error level unless specified). Safe to call when service is null (e.g. in tests). */ @@ -1080,182 +1358,295 @@ bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &lo 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. - */ -bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp) +RadioInterface::LoRaConfigNormalization RadioInterface::normalizeConfigLora(const meshtastic_Config_LoRaConfig &loraConfig, + bool clamp, + const meshtastic_ChannelSettings *primaryChannel, + RadioInterface *radio) { - char err_string[160]; - float check_bw; - - const RegionInfo *newRegion = getRegion(loraConfig.region); - - const char *presetName = DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); - - // Check preset validity (only when use_preset is true) - if (loraConfig.use_preset) { - check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora); + LoRaConfigNormalization result; + result.config = loraConfig; + float checkBw; + const RegionInfo *newRegion = getRegion(result.config.region); + + auto addDiagnostic = [&result](const LoRaConfigDiagnostic &diagnostic) { + if (result.diagnosticCount < MAX_LORA_CONFIG_DIAGNOSTICS) + result.diagnostics[result.diagnosticCount++] = diagnostic; + }; - 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 (result.config.use_preset) { + checkBw = modemPresetToBwKHz(result.config.modem_preset, newRegion->wideLora); + bool presetValid = newRegion->supportsPreset(result.config.modem_preset); + if (!presetValid) { + const RegionInfo *swapRegion = regionSwapForPreset(result.config.region, result.config.modem_preset); if (swapRegion) { + LoRaConfigDiagnostic diagnostic; + diagnostic.type = + clamp ? LoRaConfigDiagnosticType::REGION_SWAPPED : LoRaConfigDiagnosticType::REGION_SWAP_DEFERRED; + diagnostic.region = result.config.region; + diagnostic.replacementRegion = swapRegion->code; + diagnostic.preset = result.config.modem_preset; + diagnostic.corrected = clamp; + addDiagnostic(diagnostic); 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; + result.valid = false; + return result; } - 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; + result.config.region = swapRegion->code; newRegion = swapRegion; - check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora); - preset_valid = true; + checkBw = modemPresetToBwKHz(result.config.modem_preset, newRegion->wideLora); + presetValid = true; } } - if (!preset_valid) { - const char *defaultName = DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true); - if (clamp) { - snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s, using %s", presetName, newRegion->name, - defaultName); - } else { - snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s", presetName, newRegion->name); - } - LOG_ERROR("%s", err_string); - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - sendErrorNotification(err_string); - - if (clamp) { - loraConfig.modem_preset = newRegion->getDefaultPreset(); - check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora); - } else { - return false; + if (!presetValid) { + LoRaConfigDiagnostic diagnostic; + diagnostic.type = LoRaConfigDiagnosticType::INVALID_PRESET; + diagnostic.region = result.config.region; + diagnostic.preset = result.config.modem_preset; + diagnostic.corrected = clamp; + addDiagnostic(diagnostic); + if (!clamp) { + result.valid = false; + return result; } + result.config.modem_preset = newRegion->getDefaultPreset(); + checkBw = modemPresetToBwKHz(result.config.modem_preset, newRegion->wideLora); } } else { - // Clamp at the source so numFreqSlots below can never be 0 (bandwidth 0 is reachable from a crafted set_config) - check_bw = clampBandwidthKHz(bwCodeToKHz(loraConfig.bandwidth)); + checkBw = clampBandwidthKHz(bwCodeToKHz(result.config.bandwidth)); + } + + if (radio && newRegion->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && + ((newRegion->wideLora && !radio->wideLora()) || (!newRegion->wideLora && !radio->supportsSubGhz()))) { + LoRaConfigDiagnostic diagnostic; + diagnostic.type = LoRaConfigDiagnosticType::UNSUPPORTED_BAND; + diagnostic.region = result.config.region; + diagnostic.preset = result.config.modem_preset; + addDiagnostic(diagnostic); + result.valid = false; + return result; } - // Calculate width of slots (aka channels) based on bandwidth and any spacing or padding required by the region: - // spacing = gap between slots (0 for continuous spectrum) and at the beginning of the band - // padding = gap at the beginning and end of the slots (0 for no padding) - float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz + if (radio && newRegion->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && + !radio->supportsLoRaBandwidth(checkBw, newRegion->wideLora)) { + LoRaConfigDiagnostic diagnostic; + diagnostic.type = LoRaConfigDiagnosticType::UNSUPPORTED_BANDWIDTH; + diagnostic.region = result.config.region; + diagnostic.preset = result.config.modem_preset; + diagnostic.requestedBandwidthKHz = checkBw; + diagnostic.corrected = clamp; + addDiagnostic(diagnostic); + if (!clamp) { + result.valid = false; + return result; + } + + const float defaultBandwidth = modemPresetToBwKHz(newRegion->getDefaultPreset(), newRegion->wideLora); + if (result.config.use_preset) + result.config.modem_preset = newRegion->getDefaultPreset(); + else + result.config.bandwidth = bwKHzToCode(defaultBandwidth); + checkBw = defaultBandwidth; + if (!radio->supportsLoRaBandwidth(checkBw, newRegion->wideLora)) { + result.valid = false; + return result; + } + } + + float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (checkBw / 1000); uint32_t numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth); - // Check if the region supports the requested bandwidth if ((newRegion->freqEnd - newRegion->freqStart) < freqSlotWidth) { - const float regionSpanKHz = (newRegion->freqEnd - newRegion->freqStart) * 1000.0f; - snprintf(err_string, sizeof(err_string), "%s span %.0fkHz < requested %.0fkHz", newRegion->name, regionSpanKHz, check_bw); - LOG_ERROR("%s", err_string); - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - sendErrorNotification(err_string); - - if (clamp) { - loraConfig.bandwidth = bwKHzToCode(modemPresetToBwKHz(newRegion->getDefaultPreset(), newRegion->wideLora)); - check_bw = bwCodeToKHz(loraConfig.bandwidth); - - // Recompute slot width and number of slots based on the new bandwidth - freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz - numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth); - } else { - return false; + LoRaConfigDiagnostic diagnostic; + diagnostic.type = LoRaConfigDiagnosticType::BANDWIDTH_TOO_WIDE; + diagnostic.region = result.config.region; + diagnostic.requestedBandwidthKHz = checkBw; + diagnostic.corrected = clamp; + addDiagnostic(diagnostic); + if (!clamp) { + result.valid = false; + return result; } + + result.config.bandwidth = bwKHzToCode(modemPresetToBwKHz(newRegion->getDefaultPreset(), newRegion->wideLora)); + checkBw = bwCodeToKHz(result.config.bandwidth); + freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (checkBw / 1000); + numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth); } - const char *channelName = channels.getName(channels.getPrimaryIndex()); - const char *presetNameDisplay = - DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); - // numFreqSlots can still be 0 for an UNSET/degenerate region, and % 0 is a SIGFPE - uint32_t channelNameHashSlot = numFreqSlots ? (hash(channelName) % numFreqSlots) : 0; - uint32_t presetNameHashSlot = numFreqSlots ? (hash(presetNameDisplay) % numFreqSlots) : 0; - - if (loraConfig.override_frequency == 0) { - - // Check if we use the default frequency slot - // overrideSlot: 0 = channel hash, -1 = preset hash, >0 = explicit slot - uses_default_frequency_slot = - (loraConfig.channel_num == 0) || // user choice unset, no frequency override, so use default - (newRegion->overrideSlot > 0 && - loraConfig.channel_num == newRegion->overrideSlot) || // user setting matches explicit override slot - ((newRegion->overrideSlot == OVERRIDE_SLOT_DEFAULT_CHANNEL_HASH) && - ((uint32_t)(loraConfig.channel_num - 1) == channelNameHashSlot)) || // user setting matches channel name hash - ((newRegion->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) && - ((uint32_t)(loraConfig.channel_num - 1) == presetNameHashSlot)); // user setting matches preset name hash - - // check if user setting different to preset name - uses_custom_channel_name = (strcmp(channelName, presetNameDisplay) != 0); - - if (loraConfig.channel_num > numFreqSlots) { - snprintf(err_string, sizeof(err_string), "Channel number %u invalid for %s, max is %u", loraConfig.channel_num, - newRegion->name, numFreqSlots); - LOG_ERROR("%s", err_string); + const char *channelName = getChannelNameForLoRaConfig(result.config, primaryChannel); + const char *presetName = + DisplayFormatters::getModemPresetDisplayName(result.config.modem_preset, false, result.config.use_preset); + const uint32_t channelNameHashSlot = numFreqSlots ? (hash(channelName) % numFreqSlots) : 0; + const uint32_t presetNameHashSlot = numFreqSlots ? (hash(presetName) % numFreqSlots) : 0; + + if (result.config.override_frequency == 0) { + result.updatesFrequencySlotFlags = true; + result.usesDefaultFrequencySlot = (result.config.channel_num == 0) || + (newRegion->overrideSlot > 0 && result.config.channel_num == newRegion->overrideSlot) || + (newRegion->overrideSlot == OVERRIDE_SLOT_DEFAULT_CHANNEL_HASH && + static_cast(result.config.channel_num - 1) == channelNameHashSlot) || + (newRegion->overrideSlot == OVERRIDE_SLOT_PRESET_HASH && + static_cast(result.config.channel_num - 1) == presetNameHashSlot); + result.usesCustomChannelName = strcmp(channelName, presetName) != 0; + + if (result.config.channel_num > numFreqSlots) { + LoRaConfigDiagnostic diagnostic; + diagnostic.type = LoRaConfigDiagnosticType::INVALID_CHANNEL; + diagnostic.region = result.config.region; + diagnostic.channel = result.config.channel_num; + diagnostic.maxChannel = numFreqSlots; + diagnostic.corrected = clamp; + addDiagnostic(diagnostic); + if (!clamp) { + result.valid = false; + return result; + } + + if (result.usesCustomChannelName) { + result.config.channel_num = channelNameHashSlot + 1; + } else if (newRegion->overrideSlot > 0) { + result.config.channel_num = newRegion->overrideSlot; + result.usesDefaultFrequencySlot = true; + } else if (newRegion->overrideSlot == OVERRIDE_SLOT_PRESET_HASH && result.config.use_preset) { + result.config.channel_num = presetNameHashSlot + 1; + result.usesDefaultFrequencySlot = true; + } else if (result.config.use_preset) { + result.config.channel_num = presetNameHashSlot + 1; + result.usesDefaultFrequencySlot = true; + } else { + result.usesDefaultFrequencySlot = true; + } + } + } + return result; +} + +void RadioInterface::publishLoRaConfigDiagnostics(const LoRaConfigNormalization &normalization) +{ + char message[160]; + for (uint8_t i = 0; i < normalization.diagnosticCount; ++i) { + const LoRaConfigDiagnostic &diagnostic = normalization.diagnostics[i]; + const RegionInfo *region = getRegion(diagnostic.region); + const char *presetName = DisplayFormatters::getModemPresetDisplayName(diagnostic.preset, false, true); + switch (diagnostic.type) { + case LoRaConfigDiagnosticType::REGION_SWAP_DEFERRED: + LOG_INFO("Preset %s implies region swap %s to %s, deferring to clamp", presetName, region->name, + getRegion(diagnostic.replacementRegion)->name); + break; + case LoRaConfigDiagnosticType::REGION_SWAPPED: + snprintf(message, sizeof(message), "Preset %s swaps region %s to %s", presetName, region->name, + getRegion(diagnostic.replacementRegion)->name); + LOG_INFO("%s", message); + sendErrorNotification(message, meshtastic_LogRecord_Level_INFO); + break; + case LoRaConfigDiagnosticType::INVALID_PRESET: + if (diagnostic.corrected) { + const char *defaultName = DisplayFormatters::getModemPresetDisplayName(region->getDefaultPreset(), false, true); + snprintf(message, sizeof(message), "Preset %s invalid for %s, using %s", presetName, region->name, defaultName); + } else { + snprintf(message, sizeof(message), "Preset %s invalid for %s", presetName, region->name); + } + LOG_ERROR("%s", message); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(message); + break; + case LoRaConfigDiagnosticType::UNSUPPORTED_BAND: + snprintf(message, sizeof(message), "Radio does not support the %s band", region->name); + LOG_ERROR("%s", message); RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - sendErrorNotification(err_string); - - if (clamp) { - if (uses_custom_channel_name) { // clamp to channel name hash - loraConfig.channel_num = - channelNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 - } else if (newRegion->overrideSlot > 0) { // clamp to explicit override slot - loraConfig.channel_num = newRegion->overrideSlot; // use the explicit override slot defined for this region - uses_default_frequency_slot = true; - } else if (newRegion->overrideSlot == OVERRIDE_SLOT_PRESET_HASH && loraConfig.use_preset) { - // clamp to preset name hash - loraConfig.channel_num = presetNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 - uses_default_frequency_slot = true; - } else if (loraConfig.use_preset) { // clamp to preset slot - loraConfig.channel_num = presetNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 - uses_default_frequency_slot = true; - } else { // if not using preset, and no custom channel name, just clamp to default anyway - uses_default_frequency_slot = true; - }; + sendErrorNotification(message); + break; + case LoRaConfigDiagnosticType::UNSUPPORTED_BANDWIDTH: + if (diagnostic.corrected) { + snprintf(message, sizeof(message), "Radio does not support %.3fkHz in %s; using the region default", + diagnostic.requestedBandwidthKHz, region->name); } else { - return false; + snprintf(message, sizeof(message), "Radio does not support %.3fkHz in %s", diagnostic.requestedBandwidthKHz, + region->name); } - } // end of channel number check - } else { - // if we have a frequency override, we ignore the channel number and just use the override frequency - snprintf(err_string, sizeof(err_string), "Frequency override in place, using %.3f", loraConfig.override_frequency); + LOG_ERROR("%s", message); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(message); + break; + case LoRaConfigDiagnosticType::BANDWIDTH_TOO_WIDE: + snprintf(message, sizeof(message), "%s span %.0fkHz < requested %.0fkHz", region->name, + (region->freqEnd - region->freqStart) * 1000.0f, diagnostic.requestedBandwidthKHz); + LOG_ERROR("%s", message); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(message); + break; + case LoRaConfigDiagnosticType::INVALID_CHANNEL: + snprintf(message, sizeof(message), "Channel number %u invalid for %s, max is %u", diagnostic.channel, region->name, + diagnostic.maxChannel); + LOG_ERROR("%s", message); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(message); + break; + case LoRaConfigDiagnosticType::NONE: + break; + } } - return true; } -bool RadioInterface::validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig) +void RadioInterface::commitLoRaConfigFrequencyFlags(const LoRaConfigNormalization &normalization) +{ + if (!normalization.updatesFrequencySlotFlags) + return; + uses_default_frequency_slot = normalization.usesDefaultFrequencySlot; + uses_custom_channel_name = normalization.usesCustomChannelName; +} + +bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp, RadioInterface *radio) +{ + const LoRaConfigNormalization normalization = normalizeConfigLora(loraConfig, clamp, nullptr, radio); + publishLoRaConfigDiagnostics(normalization); + commitLoRaConfigFrequencyFlags(normalization); + if (clamp) + loraConfig = normalization.config; + return normalization.valid; +} + +bool RadioInterface::validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig, RadioInterface *radio) { auto copy = loraConfig; - return checkOrClampConfigLora(copy, false); + return checkOrClampConfigLora(copy, false, radio); } -void RadioInterface::clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig) +void RadioInterface::clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, RadioInterface *radio) { - checkOrClampConfigLora(loraConfig, true); + checkOrClampConfigLora(loraConfig, true, radio); } /** * Pull our channel settings etc... from protobufs to the dumb interface settings * Note: this must be given only settings which have been validated or clamped! */ -void RadioInterface::applyModemConfig() +bool RadioInterface::applyModemConfig() { - // Set up default configuration - // No Sync Words in LORA mode - meshtastic_Config_LoRaConfig &loraConfig = config.lora; + const bool stagedApply = configApplyLoraConfig != nullptr; + meshtastic_Config_LoRaConfig stagedConfig; + meshtastic_Config_LoRaConfig &loraConfig = stagedApply ? (stagedConfig = *configApplyLoraConfig, stagedConfig) : config.lora; const RegionInfo *newRegion = getRegion(loraConfig.region); - myRegion = newRegion; + bool usesDefaultFrequencySlot = uses_default_frequency_slot; + + if (stagedApply) { + LoRaConfigNormalization normalization = normalizeConfigLora(loraConfig, false, configApplyPrimaryChannel, this); + if (!normalization.valid) + normalization = normalizeConfigLora(loraConfig, true, configApplyPrimaryChannel, this); + if (!normalization.valid) { + publishLoRaConfigDiagnostics(normalization); + return false; + } + loraConfig = normalization.config; + newRegion = getRegion(loraConfig.region); + if (normalization.updatesFrequencySlotFlags) + usesDefaultFrequencySlot = normalization.usesDefaultFrequencySlot; + } else { + myRegion = newRegion; + } if (loraConfig.use_preset) { - if (!validateConfigLora(loraConfig)) { + if (!stagedApply && !validateConfigLora(loraConfig, this)) { loraConfig.modem_preset = newRegion->getDefaultPreset(); } uint8_t newcr; @@ -1273,22 +1664,25 @@ void RadioInterface::applyModemConfig() cr = newcr; } - } else { // if not using preset, then just use the custom settings - if (validateConfigLora(loraConfig)) { - } else { + } else if (!stagedApply) { + if (!validateConfigLora(loraConfig, this)) { LOG_WARN("Invalid LoRa config settings, cannot apply requested modem config - falling back to %s defaults", newRegion->name); - clampConfigLora(loraConfig); + clampConfigLora(loraConfig, this); } // Clamp at the source so numFreqSlots below can never be 0 (a bandwidth-0 config may already be persisted) bw = clampBandwidthKHz(bwCodeToKHz(loraConfig.bandwidth)); sf = loraConfig.spread_factor; cr = loraConfig.coding_rate; + } else { + bw = clampBandwidthKHz(bwCodeToKHz(loraConfig.bandwidth)); + sf = loraConfig.spread_factor; + cr = loraConfig.coding_rate; } power = loraConfig.tx_power; - if ((power == 0) || ((power > newRegion->powerLimit) && !devicestate.owner.is_licensed)) + if ((power == 0) || ((power > newRegion->powerLimit) && !getActiveLicensedOwner())) power = newRegion->powerLimit; if (power == 0) @@ -1311,7 +1705,7 @@ void RadioInterface::applyModemConfig() // Calculate hash of channel name and preset name to pick a default frequency slot if user has not specified one. // Note that channel_num is actually (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to // (numFreqSlots - 1). - const char *channelName = channels.getName(channels.getPrimaryIndex()); + const char *channelName = getChannelNameForLoRaConfig(loraConfig, configApplyPrimaryChannel); // Guard the modulo: numFreqSlots can be 0 for an UNSET/degenerate region, and % 0 is a SIGFPE uint32_t channelNameHashSlot = numFreqSlots ? (hash(channelName) % numFreqSlots) : 0; uint32_t presetNameHashSlot = @@ -1324,7 +1718,9 @@ void RadioInterface::applyModemConfig() if (loraConfig.override_frequency) { freq = loraConfig.override_frequency; channel_num = -1; - uses_default_frequency_slot = false; + usesDefaultFrequencySlot = false; + if (!stagedApply) + uses_default_frequency_slot = false; } else { // If user has not manually specified a frequency slot, or has not specified one that is different than the default or the @@ -1332,7 +1728,7 @@ void RadioInterface::applyModemConfig() // custom channel name, then use the hash of that channel name to pick a frequency slot. Note that channel_num is actually // (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to (numFreqSlots - 1). // NB: channel_num is also know as frequency slot but it's too late to fix now. - if (uses_default_frequency_slot) { + if (usesDefaultFrequencySlot) { // Handle three override slot cases: explicit slot (>0), preset hash (-1), or channel hash (0) if (newRegion->overrideSlot > 0) { channel_num = newRegion->overrideSlot - 1; // explicit override slot (1-based to 0-based) @@ -1360,7 +1756,7 @@ void RadioInterface::applyModemConfig() preambleLengthDefault; // 8 is default, but we use longer to increase the amount of sleep time when receiving } - slotTimeMsec = computeSlotTimeMsec(); + slotTimeMsec = computeSlotTimeMsec(newRegion); preambleTimeMsec = preambleLength * (pow_of_2(sf) / bw); LOG_INFO("Radio freq=%.3f, config.lora.frequency_offset=%.3f", freq, loraConfig.frequency_offset); @@ -1377,6 +1773,7 @@ void RadioInterface::applyModemConfig() LOG_INFO("channel_num: %d", channel_num + 1); LOG_INFO("frequency: %f", getFreq()); LOG_INFO("Slot time: %u msec, preamble time: %u msec", slotTimeMsec, preambleTimeMsec); + return true; } // end of applyModemConfig /** Slottime is the time to detect a transmission has started, consisting of: @@ -1384,12 +1781,13 @@ void RadioInterface::applyModemConfig() - roundtrip air propagation time (assuming max. 30km between nodes); - Tx/Rx turnaround time (maximum of SX126x and SX127x); - MAC processing time (measured on T-beam) */ -uint32_t RadioInterface::computeSlotTimeMsec() +uint32_t RadioInterface::computeSlotTimeMsec(const RegionInfo *region) { float sumPropagationTurnaroundMACTime = 0.2 + 0.4 + 7; // in milliseconds float symbolTime = pow_of_2(sf) / bw; // in milliseconds + const RegionInfo *effectiveRegion = region ? region : myRegion; - if (myRegion->wideLora) { + if (effectiveRegion->wideLora) { // CAD duration derived from AN1200.22 of SX1280 return (NUM_SYM_CAD_24GHZ + (2 * sf + 3) / 32) * symbolTime + sumPropagationTurnaroundMACTime; } else { @@ -1405,17 +1803,18 @@ uint32_t RadioInterface::computeSlotTimeMsec() void RadioInterface::limitPower(int8_t loraMaxPower) { uint8_t maxPower = 255; // No limit + const RegionInfo *activeRegion = getActiveRegion(); - if (myRegion->powerLimit) - maxPower = myRegion->powerLimit; + if (activeRegion->powerLimit) + maxPower = activeRegion->powerLimit; - if ((power > maxPower) && !devicestate.owner.is_licensed) { + if ((power > maxPower) && !getActiveLicensedOwner()) { LOG_INFO("Lower transmit power because of regulatory limits"); power = maxPower; } #if HAS_LORA_FEM - if (!devicestate.owner.is_licensed) { + if (!getActiveLicensedOwner()) { power = loraFEMInterface.powerConversion(power); } #else @@ -1429,11 +1828,11 @@ void RadioInterface::limitPower(int8_t loraMaxPower) #endif if (num_pa_points == 1) { - if (tx_gain[0] > 0 && !devicestate.owner.is_licensed) { + if (tx_gain[0] > 0 && !getActiveLicensedOwner()) { LOG_INFO("Requested Tx power: %d dBm; Device LoRa Tx gain: %d dB", power, tx_gain[0]); power -= tx_gain[0]; } - } else if (!devicestate.owner.is_licensed) { + } else if (!getActiveLicensedOwner()) { // we have an array of PA gain values. Find the highest power setting that works. for (int radio_dbm = 0; radio_dbm < (int)num_pa_points; radio_dbm++) { if (((radio_dbm + tx_gain[radio_dbm]) > power) || @@ -1454,6 +1853,10 @@ void RadioInterface::limitPower(int8_t loraMaxPower) void RadioInterface::deliverToReceiver(meshtastic_MeshPacket *p) { + if (configApplyReceptionIsHeld()) { + packetPool.release(p); + return; + } if (router) { p->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; router->enqueueReceivedMessage(p); diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index eb9315ac122..aa762835f4d 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -4,17 +4,16 @@ #include "MeshTypes.h" #include "Observer.h" #include "PointerQueue.h" +#include "RadioConfigApply.h" #include "airtime.h" #include "error.h" +#include #include #if HAS_LORA_FEM #include "LoRaFEMInterface.h" #endif -// Forward decl to avoid a direct include of generated config headers / full LoRaConfig definition in this widely-included file. -typedef struct _meshtastic_Config_LoRaConfig meshtastic_Config_LoRaConfig; - #define MAX_TX_QUEUE 16 // max number of packets which can be waiting for transmission #define MAX_LORA_PAYLOAD_LEN 255 // max length of 255 per Semtech's datasheets on SX12xx @@ -86,6 +85,10 @@ class RadioInterface CallbackObserver(this, &RadioInterface::notifyDeepSleepCb); protected: + enum class ConfigApplyPhase : uint8_t { IDLE, PREPARING, PENDING, IN_PROGRESS, COMPLETE, FINALIZING }; + static constexpr uint32_t CONFIG_APPLY_TX_BARRIER = 1UL << 31; + static constexpr uint32_t CONFIG_APPLY_TX_CLAIM_MASK = ~CONFIG_APPLY_TX_BARRIER; + bool disabled = false; float bw = 125; @@ -109,8 +112,32 @@ class RadioInterface meshtastic_MeshPacket *sendingPacket = NULL; // The packet we are currently sending uint32_t lastTxStart = 0L; - - uint32_t computeSlotTimeMsec(); + static std::atomic nextConfigApplyOwnerId; + const uint32_t configApplyOwner; + std::atomic configApplyRequest{nullptr}; + std::atomic configApplyPhase{ConfigApplyPhase::IDLE}; + std::atomic configApplyTxGate{0}; + std::atomic configApplyTxInhibit{false}; + std::atomic configApplyReceptionHeld{false}; + const meshtastic_Config_LoRaConfig *configApplyLoraConfig = nullptr; + const meshtastic_ChannelSettings *configApplyPrimaryChannel = nullptr; + bool configApplyLicensedOwner = false; + bool configApplyLicensedOwnerSet = false; + + uint32_t computeSlotTimeMsec(const RegionInfo *region = nullptr); + bool claimConfigApply(RadioConfigApplyRequest *&request); + void deferConfigApply(const RadioConfigApplyRequest *request); + void finishConfigApply(RadioConfigApplyRequest *request, RadioConfigApplyResult result); + RadioConfigApplyResult applyConfigWithRollback(RadioConfigApplyRequest &request); + virtual void holdConfigApplyReception() { configApplyReceptionHeld.store(true, std::memory_order_release); } + bool configApplyReceptionIsHeld() const { return configApplyReceptionHeld.load(std::memory_order_acquire); } + void releaseConfigApplyReception() { configApplyReceptionHeld.store(false, std::memory_order_release); } + bool configApplyPending() const; + bool configApplyBarrierIsSet() const; + bool claimConfigApplyTxStart(); + void releaseConfigApplyTxStart(); + bool configApplyTxStartActive() const; + virtual bool isActivelyReceiving() { return false; } /** * A temporary buffer used for sending/receiving packets, sized to hold the biggest buffer we might need @@ -154,6 +181,9 @@ class RadioInterface /// multiband chips like the LR1121 keep the default. virtual bool supportsSubGhz() { return true; } + /// Whether the active radio accepts this exact LoRa bandwidth in the requested band. + virtual bool supportsLoRaBandwidth(float bandwidthKHz, bool wideBand) { return true; } + /// Prepare hardware for sleep. Call this _only_ for deep sleep, not needed for light sleep. virtual bool sleep() { return true; } @@ -197,6 +227,24 @@ class RadioInterface /// \return true if initialisation succeeded. virtual bool reconfigure(); + /// Apply an ephemeral profile without publishing candidate errors as permanent device faults. + bool reconfigureTransient(); + + /// Apply the persisted profile through hardware translation while retaining fault reporting. + bool reconfigureCommitted(); + + virtual bool requestConfigApply(RadioConfigApplyRequest *request); + virtual void serviceConfigApply(uint32_t nowMsec); + virtual RadioConfigApplyResult pollConfigApply(const RadioConfigApplyRequest &request) const; + virtual bool finalizeConfigApply(RadioConfigApplyRequest *request); + virtual void setConfigApplyTxInhibit(bool inhibited); + virtual bool configApplyTxInhibited() const; + uint32_t configApplyOwnerId() const { return configApplyOwner; } + bool ownsConfigApplyRequest(const RadioConfigApplyRequest &request) const + { + return request.acceptedRadioId == configApplyOwner; + } + /** The delay to use for retransmitting dropped packets */ [[nodiscard]] uint32_t getRetransmissionMsec(const meshtastic_MeshPacket *p); @@ -253,7 +301,48 @@ class RadioInterface // Whether we have a custom channel name static bool uses_custom_channel_name; - static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp); + enum class LoRaConfigDiagnosticType : uint8_t { + NONE, + REGION_SWAP_DEFERRED, + REGION_SWAPPED, + INVALID_PRESET, + UNSUPPORTED_BAND, + UNSUPPORTED_BANDWIDTH, + BANDWIDTH_TOO_WIDE, + INVALID_CHANNEL, + }; + + struct LoRaConfigDiagnostic { + LoRaConfigDiagnosticType type = LoRaConfigDiagnosticType::NONE; + meshtastic_Config_LoRaConfig_RegionCode region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + meshtastic_Config_LoRaConfig_RegionCode replacementRegion = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + meshtastic_Config_LoRaConfig_ModemPreset preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + float requestedBandwidthKHz = 0; + uint32_t channel = 0; + uint32_t maxChannel = 0; + bool corrected = false; + }; + + static constexpr size_t MAX_LORA_CONFIG_DIAGNOSTICS = 6; + struct LoRaConfigNormalization { + meshtastic_Config_LoRaConfig config = meshtastic_Config_LoRaConfig_init_zero; + LoRaConfigDiagnostic diagnostics[MAX_LORA_CONFIG_DIAGNOSTICS] = {}; + uint8_t diagnosticCount = 0; + bool valid = true; + bool updatesFrequencySlotFlags = false; + bool usesDefaultFrequencySlot = false; + bool usesCustomChannelName = false; + }; + + void commitBootConfigCorrection(); + + static LoRaConfigNormalization normalizeConfigLora(const meshtastic_Config_LoRaConfig &loraConfig, bool clamp, + const meshtastic_ChannelSettings *primaryChannel = nullptr, + RadioInterface *radio = nullptr); + static void publishLoRaConfigDiagnostics(const LoRaConfigNormalization &normalization); + static void commitLoRaConfigFrequencyFlags(const LoRaConfigNormalization &normalization); + + static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp, RadioInterface *radio = nullptr); // Check if a candidate region is compatible and valid, with no side effects (safe for // speculative UI checks). prospectiveLicensedOwner is for a UI flow that requires @@ -266,10 +355,10 @@ class RadioInterface static bool validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig); // Check if a candidate radio configuration is valid. - static bool validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig); + static bool validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig, RadioInterface *radio = nullptr); // 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, RadioInterface *radio = nullptr); // 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. @@ -306,6 +395,14 @@ class RadioInterface */ virtual void saveChannelNum(uint32_t savedChannelNum); + const meshtastic_Config_LoRaConfig &getActiveLoRaConfig() const; + const RegionInfo *getActiveRegion() const; + bool getActiveLicensedOwner() const; + meshtastic_Config_LoRaConfig hardwareConfigFor(const meshtastic_Config_LoRaConfig &loraConfig); + bool shouldRecordReconfigureFailure() const { return configApplyLoraConfig == nullptr || recordConfigApplyFailure; } + bool reconfigureConfig(const meshtastic_Config_LoRaConfig &loraConfig, bool licensedOwner, bool recordFailure = false, + const meshtastic_ChannelSettings *primaryChannel = nullptr); + /** * Get current RSSI reading from the radio. * Returns 0 if not available. @@ -313,12 +410,16 @@ class RadioInterface virtual int16_t getCurrentRSSI() { return 0; } private: + LoRaConfigNormalization bootConfigCorrection; + bool bootConfigCorrectionPending = false; + bool recordConfigApplyFailure = false; + /** * Convert our modemConfig enum into wf, sf, etc... * * These parameters will be pull from the channelSettings global */ - void applyModemConfig(); + bool applyModemConfig(); /// Return 0 if sleep is okay. A non-NULL argument means the radio is about to be powered /// down (deep sleep / shutdown), see doPreflightSleep() @@ -326,14 +427,10 @@ class RadioInterface int notifyDeepSleepCb(void *unused = NULL); - int reloadConfig(void *unused) - { - reconfigure(); - return 0; - } + int reloadConfig(void *unused) { return reconfigureCommitted() ? 0 : 1; } }; -std::unique_ptr initLoRa(); +std::unique_ptr initLoRa(bool commitBootCorrections = true); /// Debug printing for packets void printPacket(const char *prefix, const meshtastic_MeshPacket *p); diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 5a9b292cda8..0031fb81669 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -4,6 +4,7 @@ #include "PowerMon.h" #include "SPILock.h" #include "Throttle.h" +#include "concurrency/LockGuard.h" #include "configuration.h" #include "error.h" #include "main.h" @@ -156,8 +157,8 @@ ErrorCode RadioLibInterface::send(meshtastic_MeshPacket *p) #ifndef DISABLE_WELCOME_UNSET if (config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { - if (disabled || !config.lora.tx_enabled) { - LOG_WARN("send - !config.lora.tx_enabled"); + if (disabled || !config.lora.tx_enabled || configApplyTxInhibited()) { + LOG_WARN("send - LoRa Tx disabled or inhibited"); packetPool.release(p); return ERRNO_DISABLED; } @@ -170,8 +171,8 @@ ErrorCode RadioLibInterface::send(meshtastic_MeshPacket *p) #else - if (disabled || !config.lora.tx_enabled) { - LOG_WARN("send - !config.lora.tx_enabled"); + if (disabled || !config.lora.tx_enabled || configApplyTxInhibited()) { + LOG_WARN("send - LoRa Tx disabled or inhibited"); packetPool.release(p); return ERRNO_DISABLED; } @@ -189,13 +190,24 @@ ErrorCode RadioLibInterface::send(meshtastic_MeshPacket *p) LOG_DEBUG("txGood=%d,txRelay=%d,rxGood=%d,rxBad=%d", txGood, txRelay, rxGood, rxBad); bool dropped = false; - ErrorCode res = txQueue.enqueue(p, &dropped) ? ERRNO_OK : ERRNO_UNKNOWN; + meshtastic_MeshPacket *evicted = nullptr; + ErrorCode res = txQueue.enqueue(p, &dropped, &evicted) ? ERRNO_OK : ERRNO_UNKNOWN; + + if (evicted) { +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::clearTargetRadioSettings(evicted); +#endif + packetPool.release(evicted); + } if (dropped) { txDrop++; } if (res != ERRNO_OK) { // we weren't able to queue it, so we must drop it to prevent leaks +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::clearTargetRadioSettings(p); +#endif packetPool.release(p); return res; } @@ -211,6 +223,131 @@ ErrorCode RadioLibInterface::send(meshtastic_MeshPacket *p) #endif } +bool RadioLibInterface::requestConfigApply(RadioConfigApplyRequest *request) +{ + if (!RadioInterface::requestConfigApply(request)) + return false; + + notify(CONFIG_APPLY_PENDING, false); + LOG_DEBUG("radio_config_apply queued"); + return true; +} + +bool RadioLibInterface::finalizeConfigApply(RadioConfigApplyRequest *request) +{ + if (request == nullptr) + return false; + + RadioConfigApplyRequest *complete = request; + if (configApplyFinalizeComplete.compare_exchange_strong(complete, nullptr, std::memory_order_acq_rel, + std::memory_order_acquire)) + return true; + + if (!ownsConfigApplyRequest(*request) || configApplyRequest.load(std::memory_order_acquire) != request || + configApplyPhase.load(std::memory_order_acquire) != ConfigApplyPhase::COMPLETE) + return false; + + RadioConfigApplyRequest *expected = nullptr; + if (!configApplyFinalizePending.compare_exchange_strong(expected, request, std::memory_order_acq_rel, + std::memory_order_acquire) && + expected != request) + return false; + + if (!notify(CONFIG_APPLY_PENDING, false)) + wakePreservingNotification(); + return false; +} + +void RadioLibInterface::holdConfigApplyReception() +{ + RadioInterface::holdConfigApplyReception(); + setStandby(); +} + +meshtastic_MeshPacket *RadioLibInterface::dequeueTxPacketIfConfigApplyAllowed() +{ + if (!claimConfigApplyTxStart()) + return nullptr; + meshtastic_MeshPacket *packet = txQueue.dequeue(); + releaseConfigApplyTxStart(); + return packet; +} + +void RadioLibInterface::serviceConfigApply(uint32_t nowMsec) +{ + RadioConfigApplyRequest *finalizeRequest = configApplyFinalizePending.load(std::memory_order_acquire); + if (finalizeRequest != nullptr) { + if (configApplyTxStartActive() || sendingPacket != nullptr) { + notifyLater(25, CONFIG_APPLY_PENDING, false); + return; + } + + RadioConfigApplyRequest *expected = finalizeRequest; + if (!configApplyFinalizePending.compare_exchange_strong(expected, nullptr, std::memory_order_acq_rel, + std::memory_order_acquire)) + return; + const bool finalized = RadioInterface::finalizeConfigApply(finalizeRequest); + assert(finalized); + configApplyFinalizeComplete.store(finalizeRequest, std::memory_order_release); + if (!txQueue.empty()) + setTransmitDelay(); + LOG_DEBUG("radio_config_apply resume"); + return; + } + + RadioConfigApplyRequest *request = nullptr; + if (!claimConfigApply(request)) + return; + const bool timedOut = !Throttle::isWithinTimespanMs(request->requestedAtMsec, request->timeoutMsec, nowMsec); + + if (configApplyTxStartActive() || sendingPacket != nullptr) { + if (timedOut) { + finishConfigApply(request, RadioConfigApplyResult::TIMED_OUT); + return; + } + LOG_DEBUG("radio_config_apply wait_tx"); + deferConfigApply(request); + notifyLater(25, CONFIG_APPLY_PENDING, false); + return; + } + + if (isActivelyReceiving()) { + if (timedOut) { + finishConfigApply(request, RadioConfigApplyResult::TIMED_OUT); + return; + } + deferConfigApply(request); + notifyLater(25, CONFIG_APPLY_PENDING, false); + return; + } + +#if !MESHTASTIC_EXCLUDE_BEACON + const auto beaconRestoreResult = MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); + if (beaconRestoreResult != MeshBeaconModule::RadioConfigResult::UNCHANGED) { + if (timedOut) { + finishConfigApply(request, RadioConfigApplyResult::TIMED_OUT); + return; + } + deferConfigApply(request); + if (beaconRestoreResult == MeshBeaconModule::RadioConfigResult::FAILED) + scheduleBeaconRestoreRetry(); + else + notifyLater(25, CONFIG_APPLY_PENDING, false); + return; + } +#endif + + if (timedOut) { + finishConfigApply(request, RadioConfigApplyResult::TIMED_OUT); + return; + } + + LOG_DEBUG("radio_config_apply apply"); + const RadioConfigApplyResult result = applyConfigWithRollback(*request); + finishConfigApply(request, result); + LOG_DEBUG("radio_config_apply complete result=%u", static_cast(result)); +} + meshtastic_QueueStatus RadioLibInterface::getQueueStatus() { meshtastic_QueueStatus qs; @@ -247,8 +384,12 @@ bool RadioLibInterface::isSending() bool RadioLibInterface::cancelSending(NodeNum from, PacketId id) { auto p = txQueue.remove(from, id); - if (p) + if (p) { +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::clearTargetRadioSettings(p); +#endif packetPool.release(p); // free the packet we just removed + } bool result = (p != NULL); LOG_DEBUG("cancelSending id=0x%08x, removed=%d", id, result); @@ -403,33 +544,58 @@ void RadioLibInterface::deliverPendingIrqFromPoll(PendingISR cause) void RadioLibInterface::onNotify(uint32_t notification) { +#if !MESHTASTIC_EXCLUDE_BEACON + if (MeshBeaconModule::radioRestoreIsPending() && notification != BEACON_RESTORE_PENDING) { + scheduleBeaconRestoreRetry(); + serviceConfigApply(millis()); + return; + } +#endif switch (notification) { case ISR_TX: handleTransmitInterrupt(); // completeSending() already restored the radio to the home config #if !MESHTASTIC_EXCLUDE_BEACON - // Pre-switch the radio to the NEXT queued packet's beacon config (no-op for normal traffic). - // Not required for correctness - TRANSMIT_DELAY_COMPLETED would switch before CAD anyway - but - // doing it here lets the next beacon skip the switch-only delay cycle and, more importantly, - // keeps the post-TX listen window (and the CAD/LBT that follows) on the channel we're about to - // transmit on. Only engages when the next packet is itself a beacon - exactly when we want it. - MeshBeaconModule::reconfigureForBeaconTX(this, txQueue.getFront()); + if (MeshBeaconModule::radioConfigIsTemporary()) { + scheduleBeaconRestoreRetry(); + break; + } #endif - startReceive(); + if (!(configApplyReceptionIsHeld() || configApplyTxInhibited())) + startReceive(); setTransmitDelay(); break; case ISR_RX: handleReceiveInterrupt(); - startReceive(); + if (!(configApplyReceptionIsHeld() || configApplyTxInhibited())) + startReceive(); setTransmitDelay(); break; case ISR_POLL_TICK: handleSoftwareLoraIrqPoll(); break; + case CONFIG_APPLY_PENDING: + break; + case BEACON_RESTORE_PENDING: { +#if !MESHTASTIC_EXCLUDE_BEACON + const auto restoreResult = MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); + if (restoreResult == MeshBeaconModule::RadioConfigResult::FAILED || + restoreResult == MeshBeaconModule::RadioConfigResult::IN_PROGRESS) { + scheduleBeaconRestoreRetry(); + } else { + setTransmitDelay(); + } +#endif + break; + } case TRANSMIT_DELAY_COMPLETED: // If we are not currently in receive mode, then restart the random delay (this can happen if the main thread // has placed the unit into standby) FIXME, how will this work if the chipset is in sleep mode? + if (configApplyBarrierIsSet()) { + break; + } + if (!txQueue.empty()) { if (!canSendImmediately()) { setTransmitDelay(); // currently Rx/Tx-ing: reset random delay @@ -441,35 +607,60 @@ void RadioLibInterface::onNotify(uint32_t notification) // There's still some delay pending on this packet, so resume waiting for it to elapse notifyLater(delay_remaining, TRANSMIT_DELAY_COMPLETED, txTimerOverwrite); #if !MESHTASTIC_EXCLUDE_BEACON - } else if (MeshBeaconModule::beaconTxConfigInvalid(txp)) { + } else if (MeshBeaconModule::beaconTxConfigInvalid(txp, this)) { // The beacon's target radio config is invalid (bad preset/region, or an // unlicensed node keying up on a ham-only region). Drop the packet - never // transmit it on the current (home) config - and move on to the next queued packet. - LOG_DEBUG("Beacon: invalid TX radio config, dropping packet 0x%08x", txp->id); - meshtastic_MeshPacket *bad = txQueue.dequeue(); - MeshBeaconModule::clearTargetRadioSettings(bad); - packetPool.release(bad); - setTransmitDelay(); - } else if (MeshBeaconModule::reconfigureForBeaconTX(this, txp)) { - setTransmitDelay(); -#endif + meshtastic_MeshPacket *bad = dequeueTxPacketIfConfigApplyAllowed(); + if (bad != nullptr) { + LOG_DEBUG("Beacon: invalid TX radio config, dropping packet 0x%08x", bad->id); + MeshBeaconModule::clearTargetRadioSettings(bad); + packetPool.release(bad); + setTransmitDelay(); + } } else { - if (isChannelActive()) { // check if there is currently a LoRa packet on the channel + const auto beaconConfigResult = MeshBeaconModule::reconfigureForBeaconTX(this, txp); + if (beaconConfigResult == MeshBeaconModule::RadioConfigResult::RECONFIGURED || + beaconConfigResult == MeshBeaconModule::RadioConfigResult::IN_PROGRESS) { + setTransmitDelay(); + } else if (beaconConfigResult == MeshBeaconModule::RadioConfigResult::FAILED) { + if (MeshBeaconModule::hasTargetRadioSettings(txp)) { + meshtastic_MeshPacket *bad = dequeueTxPacketIfConfigApplyAllowed(); + if (bad != nullptr) { + LOG_ERROR("Beacon: TX radio switch failed, dropping packet 0x%08x", bad->id); + MeshBeaconModule::clearTargetRadioSettings(bad); + packetPool.release(bad); + } + } + if (MeshBeaconModule::radioConfigIsTemporary()) + scheduleBeaconRestoreRetry(); + else + setTransmitDelay(); +#endif + } else { + if (isChannelActive()) { // check if there is currently a LoRa packet on the channel #if !MESHTASTIC_EXCLUDE_BEACON - if (!MeshBeaconModule::hasTargetRadioSettings(txp)) + if (!MeshBeaconModule::hasTargetRadioSettings(txp)) #endif - { - startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again + { + startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again + } + setTransmitDelay(); + } else { + // Send any outgoing packets we have ready as fast as possible to keep the time between channel scan + // and actual transmission as short as possible + if (claimConfigApplyTxStart()) { + txp = txQueue.dequeue(); + if (txp != nullptr) { + startSend(txp); + LOG_DEBUG("%d packets remain in the TX queue", txQueue.getMaxLen() - txQueue.getFree()); + } + releaseConfigApplyTxStart(); + } } - setTransmitDelay(); - } else { - // Send any outgoing packets we have ready as fast as possible to keep the time between channel scan and - // actual transmission as short as possible - txp = txQueue.dequeue(); - assert(txp); - startSend(txp); - LOG_DEBUG("%d packets remain in the TX queue", txQueue.getMaxLen() - txQueue.getFree()); +#if !MESHTASTIC_EXCLUDE_BEACON } +#endif } } } else { @@ -479,6 +670,12 @@ void RadioLibInterface::onNotify(uint32_t notification) default: assert(0); // We expected to receive a valid notification from the ISR } + +#if !MESHTASTIC_EXCLUDE_BEACON + if (MeshBeaconModule::radioRestoreIsPending()) + scheduleBeaconRestoreRetry(); +#endif + serviceConfigApply(millis()); } void RadioLibInterface::setTransmitDelay() @@ -510,6 +707,11 @@ void RadioLibInterface::setTransmitDelay() } } +void RadioLibInterface::scheduleBeaconRestoreRetry() +{ + notifyLater(25, BEACON_RESTORE_PENDING, true); +} + void RadioLibInterface::startTransmitTimer(bool withDelay) { // If we have work to do and the timer wasn't already scheduled, schedule it now @@ -538,11 +740,21 @@ void RadioLibInterface::clampToLateRebroadcastWindow(NodeNum from, PacketId id) if (p) { p->tx_after = millis() + getTxDelayMsecWeightedWorst(p->rx_snr); bool dropped = false; - if (txQueue.enqueue(p, &dropped)) { + meshtastic_MeshPacket *evicted = nullptr; + if (txQueue.enqueue(p, &dropped, &evicted)) { LOG_DEBUG("Move existing queued packet to the late rebroadcast window %dms from now", p->tx_after - millis()); } else { +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::clearTargetRadioSettings(p); +#endif packetPool.release(p); } + if (evicted) { +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::clearTargetRadioSettings(evicted); +#endif + packetPool.release(evicted); + } if (dropped) { txDrop++; } @@ -558,6 +770,9 @@ bool RadioLibInterface::removePendingTXPacket(NodeNum from, PacketId id, uint32_ meshtastic_MeshPacket *p = txQueue.remove(from, id, true, true, hop_limit_lt); if (p) { LOG_DEBUG("Dropping pending-TX packet 0x%08x with hop limit %d", p->id, p->hop_limit); +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::clearTargetRadioSettings(p); +#endif packetPool.release(p); return true; } @@ -758,16 +973,23 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) { /* NOTE: Minimize the actions before startTransmit() to keep the time between channel scan and actual transmit as low as possible to avoid collisions. */ - if (disabled || !config.lora.tx_enabled) { - LOG_WARN("Drop Tx packet because LoRa Tx disabled"); + if (disabled || !config.lora.tx_enabled || configApplyTxInhibited()) { + LOG_WARN("Drop Tx packet because LoRa Tx disabled or inhibited"); #if !MESHTASTIC_EXCLUDE_BEACON // This packet may have already triggered a beacon radio switch in TRANSMIT_DELAY_COMPLETED; // since it never reaches completeSending() here, restore the radio so it isn't left on the // beacon config (which would also break RX on the home channel). MeshBeaconModule::clearTargetRadioSettings(txp); - MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); + const auto restoreResult = MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); #endif packetPool.release(txp); +#if !MESHTASTIC_EXCLUDE_BEACON + if (restoreResult == MeshBeaconModule::RadioConfigResult::FAILED) { + scheduleBeaconRestoreRetry(); + return false; + } +#endif + setTransmitDelay(); return false; } else { configHardwareForSend(); // must be after setStandby @@ -782,7 +1004,14 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) // This send failed, but make sure to 'complete' it properly completeSending(); powerMon->clearState(meshtastic_PowerMon_State_Lora_TXOn); // Transmitter off now - startReceive(); // Restart receive mode (because startTransmit failed to put us in xmit mode) +#if !MESHTASTIC_EXCLUDE_BEACON + if (MeshBeaconModule::radioConfigIsTemporary()) { + scheduleBeaconRestoreRetry(); + } else +#endif + { + startReceive(); // Restart receive mode (because startTransmit failed to put us in xmit mode) + } } else { // Must be done AFTER, starting transmit, because startTransmit clears (possibly stale) interrupt pending register // bits diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 295ccc16039..24e2094dd99 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -2,6 +2,7 @@ #include "MeshPacketQueue.h" #include "RadioInterface.h" +#include "concurrency/Lock.h" #include "concurrency/NotifiedWorkerThread.h" #include @@ -56,7 +57,15 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified protected: /// Used as our notification from the ISR - enum PendingISR { ISR_NONE = 0, ISR_RX, ISR_TX, TRANSMIT_DELAY_COMPLETED, ISR_POLL_TICK }; + enum PendingISR { + ISR_NONE = 0, + ISR_RX, + ISR_TX, + TRANSMIT_DELAY_COMPLETED, + ISR_POLL_TICK, + CONFIG_APPLY_PENDING, + BEACON_RESTORE_PENDING + }; /** * Raw ISR handler that just calls our polymorphic method @@ -100,6 +109,10 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified bool isReceiving = false; protected: + void holdConfigApplyReception() override; + std::atomic configApplyFinalizePending{nullptr}; + std::atomic configApplyFinalizeComplete{nullptr}; + // Noise floor tracking - rolling window of samples. static const uint8_t NOISE_FLOOR_SAMPLES = 20; static const int32_t NOISE_FLOOR_DEFAULT = -120; @@ -176,6 +189,10 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified virtual ErrorCode send(meshtastic_MeshPacket *p) override; + bool requestConfigApply(RadioConfigApplyRequest *request) override; + void serviceConfigApply(uint32_t nowMsec) override; + bool finalizeConfigApply(RadioConfigApplyRequest *request) override; + /** * Return true if we think the board can go to sleep (i.e. our tx queue is empty, we are not sending or receiving) * @@ -261,17 +278,22 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified void handleTransmitInterrupt(); void handleReceiveInterrupt(); + meshtastic_MeshPacket *dequeueTxPacketIfConfigApplyAllowed(); static void timerCallback(void *p1, uint32_t p2); virtual void onNotify(uint32_t notification) override; + protected: + void scheduleBeaconRestoreRetry(); + /** start an immediate transmit * This method is virtual so subclasses can hook as needed, subclasses should not call directly * @return true if packet was sent */ virtual bool startSend(meshtastic_MeshPacket *txp); + private: meshtastic_QueueStatus getQueueStatus(); protected: diff --git a/src/mesh/STM32WLE5JCInterface.cpp b/src/mesh/STM32WLE5JCInterface.cpp index f6e4b3512a2..019695c3f5c 100644 --- a/src/mesh/STM32WLE5JCInterface.cpp +++ b/src/mesh/STM32WLE5JCInterface.cpp @@ -16,7 +16,8 @@ STM32WLE5JCInterface::STM32WLE5JCInterface(LockingArduinoHal *hal, RADIOLIB_PIN_ bool STM32WLE5JCInterface::init() { - RadioLibInterface::init(); + if (!RadioLibInterface::init()) + return false; // https://github.com/Seeed-Studio/LoRaWan-E5-Node/blob/main/Middlewares/Third_Party/SubGHz_Phy/stm32_radio_driver/radio_driver.c #if (!defined(_VARIANT_RAK3172_)) diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 7c46bee71cb..03e27063d68 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -84,7 +84,8 @@ template bool SX126xInterface::init() // FIXME: May want to set depending on a definition, currently all SX126x variant files use the DC-DC regulator option bool useRegulatorLDO = false; // Seems to depend on the connection to pin 9/DCC_SW - if an inductor DCDC? - RadioLibInterface::init(); + if (!RadioLibInterface::init()) + return false; limitPower(SX126X_MAX_POWER); // Make sure we reach the minimum power supported to turn the chip on (-9dBm) @@ -184,42 +185,42 @@ template bool SX126xInterface::init() template bool SX126xInterface::reconfigure() { - RadioLibInterface::reconfigure(); + bool success = RadioLibInterface::reconfigure(); + const meshtastic_Config_LoRaConfig &loraConfig = getActiveLoRaConfig(); - // set mode to standby - setStandby(); + const auto recordConfigError = [this, &success](const char *operation, int result) { + if (result == RADIOLIB_ERR_NONE) + return; + LOG_ERROR("SX126X %s %s%d", operation, radioLibErr, result); + if (shouldRecordReconfigureFailure()) + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + success = false; + }; + + int err = setStandby(false); + recordConfigError("standby", err); // configure publicly accessible settings - int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + err = lora.setSpreadingFactor(sf); + recordConfigError("setSpreadingFactor", err); err = lora.setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + recordConfigError("setBandwidth", err); err = lora.setCodingRate(cr); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + recordConfigError("setCodingRate", err); err = lora.setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX126X setSyncWord %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + recordConfigError("setSyncWord", err); err = lora.setCurrentLimit(currentLimit); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX126X setCurrentLimit %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + recordConfigError("setCurrentLimit", err); err = lora.setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX126X setPreambleLength %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + recordConfigError("setPreambleLength", err); err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + recordConfigError("setFrequency", err); limitPower(SX126X_MAX_POWER); // Make sure we reach the minimum power supported to turn the chip on (-9dBm) @@ -227,21 +228,18 @@ template bool SX126xInterface::reconfigure() power = -9; err = lora.setOutputPower(power); - if (err != RADIOLIB_ERR_NONE) { - // Don't abort: this power is operator config (tx_power/SX126X_MAX_POWER); a value above the - // driver's max would crash the daemon before reloadConfig() persists. Flag it and keep prior power. - LOG_ERROR("SX126X setOutputPower %d dBm rejected (%s%d); keeping previous Tx power", power, radioLibErr, err); - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - } + recordConfigError("setOutputPower", err); // Apply RX gain mode - valid in STDBY (datasheet §9.6), matches resetAGC() pattern - err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); - if (err != RADIOLIB_ERR_NONE) - LOG_WARN("SX126X setRxBoostedGainMode %s%d", radioLibErr, err); + err = lora.setRxBoostedGainMode(loraConfig.sx126x_rx_boosted_gain); + recordConfigError("setRxBoostedGainMode", err); - startReceive(); // restart receiving + if (success) { + err = startReceiveForReconfigure(); + recordConfigError("startReceive", err); + } - return true; + return success; } template int16_t SX126xInterface::getCurrentRSSI() @@ -306,7 +304,7 @@ template void SX126xInterface::handleSoftwareLoraIrqPoll() } #endif -template void SX126xInterface::setStandby() +template int SX126xInterface::setStandby(bool completePacket) { checkNotification(); // handle any pending interrupts before we force standby @@ -317,14 +315,25 @@ template void SX126xInterface::setStandby() #ifdef ARCH_PORTDUINO if (err != RADIOLIB_ERR_NONE) portduino_status.LoRa_in_error = true; -#else - assert(err == RADIOLIB_ERR_NONE); #endif + if (err != RADIOLIB_ERR_NONE) + return err; isReceiving = false; // If we were receiving, not any more activeReceiveStart = 0; disableInterrupt(); - completeSending(); // If we were sending, not anymore + if (completePacket) + completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +template void SX126xInterface::setStandby() +{ + const int err = setStandby(true); +#ifndef ARCH_PORTDUINO + assert(err == RADIOLIB_ERR_NONE); +#endif + (void)err; } /** @@ -355,34 +364,48 @@ template void SX126xInterface::startReceive() #ifdef SLEEP_ONLY sleep(); #else + setStandby(); + const int err = startReceiveForReconfigure(); + if (err != RADIOLIB_ERR_NONE) + LOG_ERROR("SX126X startReceive %s%d", radioLibErr, err); +#ifdef ARCH_PORTDUINO + (void)err; +#else + assert(err == RADIOLIB_ERR_NONE); +#endif +#endif +} + +template int SX126xInterface::startReceiveForReconfigure() +{ +#ifdef SLEEP_ONLY + sleep(); + return RADIOLIB_ERR_NONE; +#else setTransmitEnable(false); - setStandby(); #ifdef ARCH_PORTDUINO_WASM // Continuous RX in the browser: duty-cycle sleep parks BUSY high between RX // windows and stalls the slow WebUSB SPI link. No battery to save here. int err = lora.startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); - const char *rxMethod = "startReceive"; #else // We use a 16 bit preamble so this should save some power by letting radio sit in standby mostly. int err = lora.startReceiveDutyCycleAuto(preambleLength, 8, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); - const char *rxMethod = "startReceiveDutyCycleAuto"; #endif - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX126X %s %s%d", rxMethod, radioLibErr, err); #ifdef ARCH_PORTDUINO if (err != RADIOLIB_ERR_NONE) portduino_status.LoRa_in_error = true; -#else - assert(err == RADIOLIB_ERR_NONE); #endif + if (err != RADIOLIB_ERR_NONE) + return err; RadioLibInterface::startReceive(); // Must be done AFTER, starting transmit, because startTransmit clears (possibly stale) interrupt pending register bits enableInterrupt(isrRxLevel0); checkRxDoneIrqFlag(); + return RADIOLIB_ERR_NONE; #endif } @@ -525,4 +548,4 @@ template void SX126xInterface::setTransmitEnable(bool txon) #endif } -#endif \ No newline at end of file +#endif diff --git a/src/mesh/SX126xInterface.h b/src/mesh/SX126xInterface.h index 0bf977ba2ae..1620d9ed3ac 100644 --- a/src/mesh/SX126xInterface.h +++ b/src/mesh/SX126xInterface.h @@ -80,6 +80,8 @@ template class SX126xInterface : public RadioLibInterface virtual void addReceiveMetadata(meshtastic_MeshPacket *mp) override; virtual void setStandby() override; + int setStandby(bool completePacket); + int startReceiveForReconfigure(); uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } @@ -91,4 +93,4 @@ template class SX126xInterface : public RadioLibInterface /** Some boards require GPIO control of tx vs rx paths */ void setTransmitEnable(bool txon); }; -#endif \ No newline at end of file +#endif diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index 7848d51db39..8f22f296a2b 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -60,7 +60,8 @@ template bool SX128xInterface::init() #endif #endif - RadioLibInterface::init(); + if (!RadioLibInterface::init()) + return false; limitPower(SX128X_MAX_POWER); @@ -69,23 +70,9 @@ template bool SX128xInterface::init() int res = lora.begin(getFreq(), bw, sf, cr, syncWord, power, preambleLength); // \todo Display actual typename of the adapter, not just `SX128x` LOG_INFO("SX128x init result %d", res); - if (res == RADIOLIB_ERR_CHIP_NOT_FOUND || res == RADIOLIB_ERR_SPI_CMD_FAILED) + if (res != RADIOLIB_ERR_NONE) return false; - if ((config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_LORA_24) && (res == RADIOLIB_ERR_INVALID_FREQUENCY)) { - LOG_WARN("Radio only supports 2.4GHz LoRa. Adjusting Region and rebooting"); - config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; - nodeDB->saveToDisk(SEGMENT_CONFIG); - delay(2000); -#if defined(ARCH_ESP32) - ESP.restart(); -#elif defined(ARCH_NRF52) - NVIC_SystemReset(); -#else - LOG_ERROR("FIXME implement reboot for this platform. Skip for now"); -#endif - } - LOG_INFO("Frequency set to %f", getFreq()); LOG_INFO("Bandwidth set to %f", bw); LOG_INFO("Power output set to %d", power); @@ -112,48 +99,50 @@ template bool SX128xInterface::init() template bool SX128xInterface::reconfigure() { - RadioLibInterface::reconfigure(); + bool success = RadioLibInterface::reconfigure(); - // set mode to standby - setStandby(); + const auto recordConfigError = [this, &success](const char *operation, int result) { + if (result == RADIOLIB_ERR_NONE) + return; + LOG_ERROR("SX128X %s %s%d", operation, radioLibErr, result); + if (shouldRecordReconfigureFailure()) + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + success = false; + }; + + int err = setStandby(false); + recordConfigError("standby", err); // configure publicly accessible settings - int err = lora.setSpreadingFactor(sf); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + err = lora.setSpreadingFactor(sf); + recordConfigError("setSpreadingFactor", err); err = lora.setBandwidth(bw); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + recordConfigError("setBandwidth", err); err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + recordConfigError("setCodingRate", err); err = lora.setSyncWord(syncWord); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX128X setSyncWord %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + recordConfigError("setSyncWord", err); err = lora.setPreambleLength(preambleLength); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX128X setPreambleLength %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + recordConfigError("setPreambleLength", err); err = lora.setFrequency(getFreq()); - if (err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + recordConfigError("setFrequency", err); limitPower(SX128X_MAX_POWER); err = lora.setOutputPower(power); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX128X setOutputPower %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + recordConfigError("setOutputPower", err); - startReceive(); // restart receiving + if (success) { + err = startReceiveForReconfigure(); + recordConfigError("startReceive", err); + } - return true; + return success; } template void SX128xInterface::disableInterrupt() @@ -166,7 +155,12 @@ template bool SX128xInterface::wideLora() return true; } -template void SX128xInterface::setStandby() +template bool SX128xInterface::supportsLoRaBandwidth(float bandwidthKHz, bool wideBand) +{ + return wideBand && (bandwidthKHz == 203.125f || bandwidthKHz == 406.25f || bandwidthKHz == 812.5f || bandwidthKHz == 1625.0f); +} + +template int SX128xInterface::setStandby(bool completePacket) { checkNotification(); // handle any pending interrupts before we force standby @@ -174,7 +168,6 @@ template void SX128xInterface::setStandby() if (err != RADIOLIB_ERR_NONE) LOG_ERROR("SX128x standby %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); #if ARCH_PORTDUINO if (portduino_config.lora_rxen_pin.pin != RADIOLIB_NC) { digitalWrite(portduino_config.lora_rxen_pin.pin, LOW); @@ -193,8 +186,17 @@ template void SX128xInterface::setStandby() isReceiving = false; // If we were receiving, not any more activeReceiveStart = 0; disableInterrupt(); - completeSending(); // If we were sending, not anymore + if (completePacket) + completeSending(); // If we were sending, not anymore RadioLibInterface::setStandby(); + return err; +} + +template void SX128xInterface::setStandby() +{ + const int err = setStandby(true); + assert(err == RADIOLIB_ERR_NONE); + (void)err; } /** @@ -241,9 +243,22 @@ template void SX128xInterface::startReceive() #ifdef SLEEP_ONLY sleep(); #else - setStandby(); + const int err = startReceiveForReconfigure(); + if (err != RADIOLIB_ERR_NONE) + LOG_ERROR("SX128X startReceive %s%d", radioLibErr, err); + assert(err == RADIOLIB_ERR_NONE); +#endif +} + +template int SX128xInterface::startReceiveForReconfigure() +{ +#ifdef SLEEP_ONLY + sleep(); + return RADIOLIB_ERR_NONE; +#else + #if ARCH_PORTDUINO if (portduino_config.lora_rxen_pin.pin != RADIOLIB_NC) { digitalWrite(portduino_config.lora_rxen_pin.pin, HIGH); @@ -262,16 +277,15 @@ template void SX128xInterface::startReceive() #endif int err = lora.startReceive(RADIOLIB_SX128X_RX_TIMEOUT_INF, MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS); - if (err != RADIOLIB_ERR_NONE) - LOG_ERROR("SX128X startReceive %s%d", radioLibErr, err); - assert(err == RADIOLIB_ERR_NONE); + return err; RadioLibInterface::startReceive(); // Must be done AFTER, starting transmit, because startTransmit clears (possibly stale) interrupt pending register bits enableInterrupt(isrRxLevel0); checkRxDoneIrqFlag(); + return RADIOLIB_ERR_NONE; #endif } @@ -332,4 +346,4 @@ template int16_t SX128xInterface::getCurrentRSSI() float rssi = lora.getRSSI(false); return (int16_t)round(rssi); } -#endif \ No newline at end of file +#endif diff --git a/src/mesh/SX128xInterface.h b/src/mesh/SX128xInterface.h index 1205087b719..11d3d61d2be 100644 --- a/src/mesh/SX128xInterface.h +++ b/src/mesh/SX128xInterface.h @@ -22,6 +22,8 @@ template class SX128xInterface : public RadioLibInterface /// SX128x is a 2.4 GHz-only chip; it cannot tune sub-GHz regions virtual bool supportsSubGhz() override { return false; } + bool supportsLoRaBandwidth(float bandwidthKHz, bool wideBand) override; + /// Apply any radio provisioning changes /// Make sure the Driver is properly configured before calling init(). /// \return true if initialisation succeeded. @@ -72,6 +74,8 @@ template class SX128xInterface : public RadioLibInterface virtual void addReceiveMetadata(meshtastic_MeshPacket *mp) override; virtual void setStandby() override; + int setStandby(bool completePacket); + int startReceiveForReconfigure(); uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } }; diff --git a/src/mesh/Throttle.cpp b/src/mesh/Throttle.cpp index a4f8347b263..9de0c60f18f 100644 --- a/src/mesh/Throttle.cpp +++ b/src/mesh/Throttle.cpp @@ -31,6 +31,10 @@ bool Throttle::execute(uint32_t *lastExecutionMs, uint32_t minumumIntervalMs, vo /// @param timeSpanMs The interval in milliseconds of the timespan bool Throttle::isWithinTimespanMs(uint32_t lastExecutionMs, uint32_t timeSpanMs) { - uint32_t now = millis(); - return (now - lastExecutionMs) < timeSpanMs; -} \ No newline at end of file + return isWithinTimespanMs(lastExecutionMs, timeSpanMs, millis()); +} + +bool Throttle::isWithinTimespanMs(uint32_t lastExecutionMs, uint32_t timeSpanMs, uint32_t nowMs) +{ + return (nowMs - lastExecutionMs) < timeSpanMs; +} diff --git a/src/mesh/Throttle.h b/src/mesh/Throttle.h index 8b4bb5d3054..6c5b03a1674 100644 --- a/src/mesh/Throttle.h +++ b/src/mesh/Throttle.h @@ -7,4 +7,5 @@ class Throttle public: static bool execute(uint32_t *lastExecutionMs, uint32_t minumumIntervalMs, void (*func)(void), void (*onDefer)(void) = NULL); static bool isWithinTimespanMs(uint32_t lastExecutionMs, uint32_t intervalMs); -}; \ No newline at end of file + static bool isWithinTimespanMs(uint32_t lastExecutionMs, uint32_t intervalMs, uint32_t nowMs); +}; diff --git a/src/mesh/raspihttp/PiWebServer.cpp b/src/mesh/raspihttp/PiWebServer.cpp index b4c99cd2742..5f935209096 100644 --- a/src/mesh/raspihttp/PiWebServer.cpp +++ b/src/mesh/raspihttp/PiWebServer.cpp @@ -71,6 +71,8 @@ mail: marchammermann@googlemail.com #include #include +#include +#include #include #include @@ -102,6 +104,132 @@ volatile bool isCertReady; PiWebServerThread *piwebServerThread; +bool HttpAPI::submit(const std::shared_ptr &request) +{ + { + std::lock_guard guard(requestMutex); + if (!acceptingRequests || requests.size() >= REQUEST_QUEUE_SIZE) + return false; + requests.push_back(request); + } + + concurrency::mainDelay.interrupt(); + std::unique_lock completionLock(request->completionMutex); + if (!request->completion.wait_for(completionLock, std::chrono::milliseconds(REQUEST_TIMEOUT_MS), + [&request] { return request->completed; })) { + request->cancelled = true; + completionLock.unlock(); + std::lock_guard guard(requestMutex); + auto queued = std::find(requests.begin(), requests.end(), request); + if (queued != requests.end()) + requests.erase(queued); + return false; + } + return !request->cancelled; +} + +bool HttpAPI::submitToRadio(const uint8_t *data, size_t length) +{ + if (!data || length == 0 || length > MAX_TO_FROM_RADIO_SIZE) + return false; + + auto request = std::make_shared(); + request->type = RequestType::TO_RADIO; + memcpy(request->data.data(), data, length); + request->length = length; + return submit(request); +} + +bool HttpAPI::submitFromRadio(uint8_t *data, size_t &length) +{ + if (!data) + return false; + + auto request = std::make_shared(); + request->type = RequestType::FROM_RADIO; + if (!submit(request)) + return false; + + memcpy(data, request->data.data(), request->length); + length = request->length; + return true; +} + +bool HttpAPI::hasPendingRequests() +{ + return pendingRequestCount() != 0; +} + +size_t HttpAPI::pendingRequestCount() +{ + std::lock_guard guard(requestMutex); + return requests.size(); +} + +void HttpAPI::processPendingRequests() +{ + std::deque> pending; + { + std::lock_guard guard(requestMutex); + const size_t count = requests.size(); + for (size_t i = 0; i < count; ++i) { + pending.push_back(requests.front()); + inFlightRequests.push_back(requests.front()); + requests.pop_front(); + } + } + + for (const auto &request : pending) { + bool cancelled = false; + { + std::lock_guard completionGuard(request->completionMutex); + cancelled = request->cancelled; + } + if (!cancelled) { + if (request->type == RequestType::TO_RADIO) + handleToRadio(request->data.data(), request->length); + else + request->length = getFromRadio(request->data.data()); + } + + { + std::lock_guard completionGuard(request->completionMutex); + request->completed = true; + } + request->completion.notify_one(); + + { + std::lock_guard guard(requestMutex); + auto entry = std::find(inFlightRequests.begin(), inFlightRequests.end(), request); + if (entry != inFlightRequests.end()) + inFlightRequests.erase(entry); + } + requestDrain.notify_all(); + } +} + +void HttpAPI::stopAcceptingRequests() +{ + std::deque> cancelled; + { + std::lock_guard guard(requestMutex); + acceptingRequests = false; + cancelled.swap(requests); + } + + for (const auto &request : cancelled) { + { + std::lock_guard completionGuard(request->completionMutex); + request->cancelled = true; + request->completed = true; + } + request->completion.notify_one(); + } + + std::unique_lock guard(requestMutex); + requestDrain.wait(guard, [this] { return inFlightRequests.empty(); }); +} + /** * Return the filename extension */ @@ -251,17 +379,18 @@ int handleAPIv1ToRadio(const struct _u_request *req, struct _u_response *res, vo return U_CALLBACK_COMPLETE; } - byte buffer[MAX_TO_FROM_RADIO_SIZE]; size_t s = req->binary_body_length; - memcpy(buffer, req->binary_body, MAX_TO_FROM_RADIO_SIZE); - - // FIXME* Problem with portdunio loosing mountpoint maybe because of running in a real sep. thread - - portduinoVFS->mountpoint(configWeb.rootPath); + if (s == 0 || s > MAX_TO_FROM_RADIO_SIZE) { + ulfius_set_string_body_response(res, 400, "Invalid ToRadio payload size"); + return U_CALLBACK_COMPLETE; + } - LOG_DEBUG("Received %d bytes from PUT request", s); - static_cast(user_data)->handleToRadio(buffer, s); + LOG_DEBUG("Received %zu bytes from PUT request", s); + if (!static_cast(user_data)->submitToRadio(static_cast(req->binary_body), s)) { + ulfius_set_string_body_response(res, 503, "ToRadio unavailable"); + return U_CALLBACK_COMPLETE; + } LOG_DEBUG("end web->radio "); return U_CALLBACK_COMPLETE; } @@ -289,21 +418,27 @@ int handleAPIv1FromRadio(const struct _u_request *req, struct _u_response *res, } uint8_t txBuf[MAX_STREAM_BUF_SIZE]; - uint32_t len = 1; + size_t len = 0; if (valueAll == "true") { - while (len) { - len = static_cast(user_data)->getFromRadio(txBuf); + do { + if (!static_cast(user_data)->submitFromRadio(txBuf, len)) { + ulfius_set_string_body_response(res, 503, "FromRadio unavailable"); + return U_CALLBACK_COMPLETE; + } ulfius_set_response_properties(res, U_OPT_STATUS, 200, U_OPT_BINARY_BODY, txBuf, len); const char *tmpa = (const char *)txBuf; ulfius_set_string_body_response(res, 200, tmpa); // LOG_DEBUG("\n----webAPI response all:----"); // LOG_DEBUG(tmpa); // LOG_DEBUG(""); - } + } while (len); // Otherwise, just return one protobuf } else { - len = static_cast(user_data)->getFromRadio(txBuf); + if (!static_cast(user_data)->submitFromRadio(txBuf, len)) { + ulfius_set_string_body_response(res, 503, "FromRadio unavailable"); + return U_CALLBACK_COMPLETE; + } const char *tmpa = (const char *)txBuf; ulfius_set_binary_body_response(res, 200, tmpa, len); // LOG_DEBUG("\n----webAPI response:"); @@ -535,11 +670,21 @@ PiWebServerThread::PiWebServerThread() } } -PiWebServerThread::~PiWebServerThread() +void PiWebServerThread::processPendingRequests() { - u_map_clean(&configWeb.mime_types); + if (webAPI.hasPendingRequests()) { + const char *previousMountpoint = portduinoVFS->mountpoint(); + portduinoVFS->mountpoint(configWeb.rootPath); + webAPI.processPendingRequests(); + portduinoVFS->mountpoint(previousMountpoint); + } +} +PiWebServerThread::~PiWebServerThread() +{ + webAPI.stopAcceptingRequests(); ulfius_stop_framework(&instanceWeb); + u_map_clean(&configWeb.mime_types); ulfius_clean_instance(&instanceWeb); free(configWeb.rootPath); free(key_pem); @@ -548,4 +693,4 @@ PiWebServerThread::~PiWebServerThread() } #endif -#endif \ No newline at end of file +#endif diff --git a/src/mesh/raspihttp/PiWebServer.h b/src/mesh/raspihttp/PiWebServer.h index 24b7de4b158..6d5c2822b8d 100644 --- a/src/mesh/raspihttp/PiWebServer.h +++ b/src/mesh/raspihttp/PiWebServer.h @@ -9,7 +9,12 @@ #include "ulfius-cfg.h" #include "ulfius.h" #include +#include +#include +#include #include +#include +#include #define STATIC_FILE_CHUNK 256 @@ -36,10 +41,33 @@ class HttpAPI : public PhoneAPI /// Check the current underlying physical link to see if the client is currently connected virtual bool checkIsConnected() override { return true; } // FIXME, be smarter about this + bool submitToRadio(const uint8_t *data, size_t length); + bool submitFromRadio(uint8_t *data, size_t &length); + bool hasPendingRequests(); + size_t pendingRequestCount(); + void processPendingRequests(); + void stopAcceptingRequests(); + private: - // Nothing here yet + static constexpr size_t REQUEST_QUEUE_SIZE = 8; + static constexpr uint32_t REQUEST_TIMEOUT_MS = 5000; + enum class RequestType : uint8_t { TO_RADIO, FROM_RADIO }; + struct PendingRequest { + RequestType type; + std::array data{}; + size_t length = 0; + std::mutex completionMutex; + std::condition_variable completion; + bool completed = false; + bool cancelled = false; + }; - protected: + bool submit(const std::shared_ptr &request); + std::mutex requestMutex; + std::condition_variable requestDrain; + std::deque> requests; + std::deque> inFlightRequests; + bool acceptingRequests = true; }; class PiWebServerThread @@ -54,6 +82,7 @@ class PiWebServerThread public: PiWebServerThread(); ~PiWebServerThread(); + void processPendingRequests(); int CreateSSLCertificate(); int CheckSSLandLoad(); uint32_t requestRestart = 0; @@ -63,4 +92,4 @@ class PiWebServerThread extern PiWebServerThread *piwebServerThread; #endif -#endif \ No newline at end of file +#endif diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 98a9c9d934c..ed9d9cfb770 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -8,6 +8,7 @@ #include "PositionPrecision.h" #include "PowerFSM.h" #include "SPILock.h" +#include "concurrency/LockGuard.h" #include "gps/RTC.h" #include "input/InputBroker.h" #include "meshUtils.h" @@ -15,6 +16,7 @@ #include #include #include // for better whitespace handling +#include #if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI #include "MeshtasticOTA.h" #endif @@ -240,7 +242,37 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta } } // Before the switch, so every case below sees consistent transaction state. - expireStaleEditTransaction(); + if (!expireStaleEditTransaction()) { + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); + return handled; + } + + if (loRaConfigApplyPending.load(std::memory_order_acquire)) { + bool interruptsRadioApply = false; + switch (r->which_payload_variant) { + case meshtastic_AdminMessage_reboot_seconds_tag: + interruptsRadioApply = r->reboot_seconds >= 0; + break; + case meshtastic_AdminMessage_shutdown_seconds_tag: + interruptsRadioApply = r->shutdown_seconds >= 0; + break; + case meshtastic_AdminMessage_ota_request_tag: + case meshtastic_AdminMessage_factory_reset_config_tag: + case meshtastic_AdminMessage_factory_reset_device_tag: + case meshtastic_AdminMessage_nodedb_reset_tag: + case meshtastic_AdminMessage_enter_dfu_mode_request_tag: + case meshtastic_AdminMessage_restore_preferences_tag: + interruptsRadioApply = true; + break; + default: + break; + } + if (interruptsRadioApply) { + LOG_WARN("Rejecting admin operation while radio configuration apply is pending"); + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); + return handled; + } + } switch (r->which_payload_variant) { @@ -288,6 +320,10 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta */ case meshtastic_AdminMessage_set_owner_tag: LOG_DEBUG("Client set owner"); + if (loRaConfigApplyPending.load(std::memory_order_acquire) && owner.is_licensed != r->set_owner.is_licensed) { + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); + break; + } // Validate names if (*r->set_owner.long_name) { const char *start = r->set_owner.long_name; @@ -310,36 +346,23 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta break; } } - handleSetOwner(r->set_owner); + if (!handleSetOwner(r->set_owner)) + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); break; case meshtastic_AdminMessage_set_config_tag: { LOG_DEBUG("Client set config"); - // Non-LoRa configs need no further validation. - if (r->set_config.which_payload_variant != meshtastic_Config_lora_tag) { - LOG_DEBUG("Non-LoRa config, applying directly"); - handleSetConfig(r->set_config, fromOthers); - break; - } - - // Only LORA_24 requires hardware capability validation. - if (r->set_config.payload_variant.lora.region != meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { - LOG_DEBUG("LoRa config, region is not LORA_24, applying directly"); - handleSetConfig(r->set_config, fromOthers); - break; - } - - // Hardware supports 2.4 GHz - apply the config. - // Fail closed: null instance is treated as incapable. - if (RadioLibInterface::instance && RadioLibInterface::instance->wideLora()) { - LOG_DEBUG("LORA_24 requested, radio hardware supports 2.4 GHz, applying"); - handleSetConfig(r->set_config, fromOthers); + if (r->set_config.which_payload_variant == meshtastic_Config_lora_tag && + r->set_config.payload_variant.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 && + RadioLibInterface::instance && !RadioLibInterface::instance->wideLora()) { + LOG_WARN("Radio hardware does not support 2.4 GHz; rejecting LORA_24 region"); + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); break; } - LOG_WARN("Radio hardware does not support 2.4 GHz; rejecting LORA_24 region"); - myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); + if (!handleSetConfig(r->set_config, fromOthers)) + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); break; } @@ -365,8 +388,8 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta LOG_DEBUG("Client set channel %d", r->set_channel.index); if (r->set_channel.index < 0 || r->set_channel.index >= (int)MAX_NUM_CHANNELS) myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); - else - handleSetChannel(r->set_channel); + else if (!handleSetChannel(r->set_channel)) + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); break; case meshtastic_AdminMessage_set_ham_mode_tag: LOG_DEBUG("Client set ham mode"); @@ -482,6 +505,10 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta } case meshtastic_AdminMessage_begin_edit_settings_tag: { LOG_INFO("Begin transaction for editing settings"); + if (loRaConfigApplyPending.load(std::memory_order_acquire)) { + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); + break; + } hasOpenEditTransaction = true; editTransactionActivityMs = millis(); break; @@ -489,10 +516,48 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta case meshtastic_AdminMessage_commit_edit_settings_tag: { disableBluetooth(); LOG_INFO("Commit transaction for edited settings"); + normalizePendingChannelPrimary(); + const bool hasStagedLoRaConfig = (pendingLoRaConfig.saveWhat & ~SEGMENT_CHANNELS) != 0; + const bool requiresRadioApply = hasStagedLoRaConfig || pendingChannelNeedsRadioApply(); + if (requiresRadioApply && (!service || !tryBeginLoRaConfigApply())) { + editTransactionActivityMs = millis(); + sendWarningAndLog("Radio configuration apply is busy; edited settings remain staged for retry"); + break; + } hasOpenEditTransaction = false; + const int editedSegments = deferredEditSegments; deferredEditSegments = 0; - saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE); - flushChannelWarnings(); // one coalesced message for everything edited in this transaction + if (requiresRadioApply) { + const int unrelatedSegments = pendingChannelConfig.active ? editedSegments & ~SEGMENT_CHANNELS : editedSegments; + if (!service->requestLoRaConfig(pendingLoRaConfig.previous, pendingLoRaConfig.candidate, LORA_CONFIG_APPLY_TIMEOUT_MS, + pendingLoRaConfig.previousLicensed, pendingLoRaConfig.candidateLicensed, + pendingChannelConfig.active ? &pendingChannelConfig.previousPrimary : nullptr, + pendingChannelConfig.active ? &pendingChannelConfig.candidatePrimary : nullptr)) { + pendingChannelConfig = PendingChannelConfig{}; + pendingOwnerConfig = PendingOwnerConfig{}; + clearPreparedLoRaConfig(pendingLoRaConfig); + pendingMenuLoRaTransition = StagedMenuLoRaTransition{}; + cancelLoRaConfigApply(); + sendWarningAndLog("Radio configuration apply could not be queued; previous configuration retained"); + } + if (unrelatedSegments) + saveChanges(unrelatedSegments); + } else { + const int unrelatedSegments = editedSegments & ~SEGMENT_CHANNELS; + if (unrelatedSegments) + saveChanges(unrelatedSegments); + if (pendingChannelConfig.active) { + publishPendingChannels(); + saveChanges(SEGMENT_CHANNELS, false, false); + queuePendingChannelWarnings(); + pendingChannelConfig = PendingChannelConfig{}; + clearPreparedLoRaConfig(pendingLoRaConfig); + } + if (!editedSegments) + saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | + SEGMENT_NODEDATABASE); + flushChannelWarnings(); + } break; } case meshtastic_AdminMessage_get_device_connection_status_request_tag: { @@ -778,11 +843,13 @@ void AdminModule::handleGetModuleConfigResponse(const meshtastic_MeshPacket &mp, * Setter methods */ -void AdminModule::handleSetOwner(const meshtastic_User &o) +bool AdminModule::handleSetOwner(const meshtastic_User &o) { + if (loRaConfigApplyPending.load(std::memory_order_acquire)) + return false; + + meshtastic_User candidate = pendingOwnerConfig.active ? pendingOwnerConfig.candidate : owner; int changed = 0; - bool identityUpdated = false; - bool channelsSanitized = false; if (*o.long_name) { // Apps built against the older 39-byte limit may send longer names; clamp @@ -791,50 +858,87 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) strncpy(longName, o.long_name, sizeof(longName)); longName[sizeof(longName) - 1] = '\0'; clampLongName(longName); - changed |= strcmp(owner.long_name, longName); - strncpy(owner.long_name, longName, sizeof(owner.long_name)); - owner.long_name[sizeof(owner.long_name) - 1] = '\0'; + changed |= strcmp(candidate.long_name, longName); + strncpy(candidate.long_name, longName, sizeof(candidate.long_name)); + candidate.long_name[sizeof(candidate.long_name) - 1] = '\0'; } if (*o.short_name) { - changed |= strcmp(owner.short_name, o.short_name); - strncpy(owner.short_name, o.short_name, sizeof(owner.short_name)); - owner.short_name[sizeof(owner.short_name) - 1] = '\0'; - sanitizeUtf8(owner.short_name, sizeof(owner.short_name)); + changed |= strcmp(candidate.short_name, o.short_name); + strncpy(candidate.short_name, o.short_name, sizeof(candidate.short_name)); + candidate.short_name[sizeof(candidate.short_name) - 1] = '\0'; + sanitizeUtf8(candidate.short_name, sizeof(candidate.short_name)); } - if (owner.is_licensed != o.is_licensed) { + if (pendingOwnerConfig.active && candidate.is_licensed != o.is_licensed) + return false; + + const bool licenseChanged = owner.is_licensed != o.is_licensed; + if (candidate.is_licensed != o.is_licensed) { changed = 1; -#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - const bool identityWillMigrate = - o.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && licensedIdentityWillMigrate(); - if (identityWillMigrate) - sendWarning(licensedIdentityMigrationMessage); -#endif - owner.is_licensed = o.is_licensed; - if (channels.ensureLicensedOperation()) { - warnLicensedMode(); - channelsSanitized = true; - } -#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { - identityUpdated = nodeDB->generateCryptoKeyPair(); - if (identityWillMigrate) - nodeDB->licensedIdentityMigrationPending = false; - } -#endif + candidate.is_licensed = o.is_licensed; } - snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum()); - if (owner.has_is_unmessagable != o.has_is_unmessagable || - (o.has_is_unmessagable && owner.is_unmessagable != o.is_unmessagable)) { + snprintf(candidate.id, sizeof(candidate.id), "!%08x", nodeDB->getNodeNum()); + if (candidate.has_is_unmessagable != o.has_is_unmessagable || + (o.has_is_unmessagable && candidate.is_unmessagable != o.is_unmessagable)) { changed = 1; - owner.has_is_unmessagable = owner.has_is_unmessagable || o.has_is_unmessagable; - owner.is_unmessagable = o.is_unmessagable; + candidate.has_is_unmessagable = candidate.has_is_unmessagable || o.has_is_unmessagable; + candidate.is_unmessagable = o.is_unmessagable; } - if (changed) { // If nothing really changed, don't broadcast on the network or write to flash - service->reloadOwner(!hasOpenEditTransaction); - saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE | (identityUpdated ? SEGMENT_CONFIG : 0) | - (channelsSanitized ? SEGMENT_CHANNELS : 0)); + if (!changed) + return true; + + if (licenseChanged) { + const PendingChannelConfig savedChannels = pendingChannelConfig; + const PendingOwnerConfig savedOwner = pendingOwnerConfig; + pendingOwnerConfig.previous = owner; + pendingOwnerConfig.candidate = candidate; + pendingOwnerConfig.active = true; + ensurePendingChannelConfig(); + pendingChannelConfig.licensedChannelsSanitized |= + Channels::ensureLicensedOperation(pendingChannelConfig.candidate, candidate.is_licensed); + + const meshtastic_Config_LoRaConfig radioCandidate = + pendingLoRaConfig.saveWhat != 0 ? pendingLoRaConfig.candidate : config.lora; + const PreparedLoRaConfig previousPrepared = pendingLoRaConfig; + if (!requestLoRaConfig(radioCandidate, false, candidate.is_licensed, StagedMenuLoRaTransition{})) { + pendingChannelConfig = savedChannels; + pendingOwnerConfig = savedOwner; + return false; + } + pendingLoRaConfig.channelNumAutoCorrected |= previousPrepared.channelNumAutoCorrected; + pendingLoRaConfig.preserveDefaultFrequencySlot = previousPrepared.preserveDefaultFrequencySlot; + if (previousPrepared.warnCodingRateNormalization) { + pendingLoRaConfig.warnCodingRateNormalization = true; + pendingLoRaConfig.invalidCodingRate = previousPrepared.invalidCodingRate; + } + if (previousPrepared.warnSpreadFactorNormalization) { + pendingLoRaConfig.warnSpreadFactorNormalization = true; + pendingLoRaConfig.invalidSpreadFactor = previousPrepared.invalidSpreadFactor; + } + if (previousPrepared.warnBandwidthNormalization) { + pendingLoRaConfig.warnBandwidthNormalization = true; + pendingLoRaConfig.invalidBandwidth = previousPrepared.invalidBandwidth; + } + pendingLoRaConfig.warnInvalidClientCorrection |= previousPrepared.warnInvalidClientCorrection; + pendingLoRaConfig.warnFemNormalization |= previousPrepared.warnFemNormalization; + for (uint8_t i = 0; i < previousPrepared.delayedDiagnosticCount && + pendingLoRaConfig.delayedDiagnosticCount < PreparedLoRaConfig::MAX_DELAYED_DIAGNOSTICS; + ++i) { + pendingLoRaConfig.delayedDiagnostics[pendingLoRaConfig.delayedDiagnosticCount++] = + previousPrepared.delayedDiagnostics[i]; + } + pendingLoRaConfig.saveWhat |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE | SEGMENT_CHANNELS; + if (hasOpenEditTransaction) + saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE | SEGMENT_CHANNELS); + return true; } + + if (pendingOwnerConfig.active) + pendingOwnerConfig.candidate = candidate; + owner = candidate; + service->reloadOwner(!hasOpenEditTransaction); + saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE); + return true; } #if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \ @@ -875,14 +979,467 @@ static bool isBareKeypairRotation(const meshtastic_Config_SecurityConfig &incomi meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE; } -void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) +void AdminModule::clearPreparedLoRaConfig(PreparedLoRaConfig &prepared) +{ + static_assert(std::is_trivially_copyable::value, "Prepared LoRa state must remain byte-resettable"); + memset(static_cast(&prepared), 0, sizeof(prepared)); +} + +bool AdminModule::prepareLoRaConfig(const meshtastic_Config_LoRaConfig &incoming, bool fromOthers, bool prospectiveLicensedOwner, + PreparedLoRaConfig &prepared) +{ + clearPreparedLoRaConfig(prepared); + prepared.previous = config.lora; +#if !MESHTASTIC_EXCLUDE_BEACON + meshtastic_ChannelSettings homePrimaryChannel; + MeshBeaconModule::getHomeRadioConfig(prepared.previous, &homePrimaryChannel); + const meshtastic_ChannelSettings *normalizationPrimaryChannel = + pendingChannelConfig.active ? &pendingChannelConfig.candidatePrimary : &homePrimaryChannel; +#else + const meshtastic_ChannelSettings *normalizationPrimaryChannel = + pendingChannelConfig.active ? &pendingChannelConfig.candidatePrimary : nullptr; +#endif + RadioInterface *candidateRadio = router ? router->getRadioIface() : nullptr; + prepared.candidate = incoming; + prepared.previousLicensed = owner.is_licensed; + prepared.candidateLicensed = prospectiveLicensedOwner; + prepared.saveWhat = SEGMENT_CONFIG; + prepared.fanDisabled = incoming.pa_fan_disabled; + + const bool isRegionUnset = prepared.previous.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET; + const RegionInfo *activeRegion = getRegion(prepared.previous.region); + bool regionSideEffectsPending = false; + + LOG_INFO("Set config: LoRa"); + + auto delayDiagnostics = [&prepared](const RadioInterface::LoRaConfigNormalization &normalization) { + for (uint8_t i = 0; + i < normalization.diagnosticCount && prepared.delayedDiagnosticCount < PreparedLoRaConfig::MAX_DELAYED_DIAGNOSTICS; + ++i) { + prepared.delayedDiagnostics[prepared.delayedDiagnosticCount++] = normalization.diagnostics[i]; + } + }; + + if (prepared.candidate.coding_rate != clampCodingRate(prepared.candidate.coding_rate)) { + prepared.warnCodingRateNormalization = true; + prepared.invalidCodingRate = prepared.candidate.coding_rate; + prepared.candidate.coding_rate = LORA_CR_DEFAULT; + } + + if (prepared.candidate.spread_factor != clampSpreadFactor(prepared.candidate.spread_factor)) { + prepared.warnSpreadFactorNormalization = true; + prepared.invalidSpreadFactor = prepared.candidate.spread_factor; + prepared.candidate.spread_factor = LORA_SF_DEFAULT; + } + + const uint16_t clampedBandwidth = clampBandwidthCode(prepared.candidate.bandwidth); + if (!prepared.candidate.use_preset && prepared.candidate.bandwidth != clampedBandwidth) { + prepared.warnBandwidthNormalization = true; + prepared.invalidBandwidth = prepared.candidate.bandwidth; + prepared.candidate.bandwidth = clampedBandwidth; + } + + if (prepared.candidate.region != activeRegion->code) { + const bool regionValid = RadioInterface::checkConfigRegion(prepared.candidate, nullptr, 0, prospectiveLicensedOwner); + if (regionValid) { + if (isRegionUnset && prepared.candidate.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (prospectiveLicensedOwner) { + prepared.generateLicensedIdentity = true; + prepared.warnLicensedIdentityMigration = licensedIdentityWillMigrate(); + prepared.saveWhat |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; + } else { + prepared.ensurePkiKeys = true; + } +#endif + prepared.candidate.tx_enabled = true; + } + if (!isRegionUnset && prepared.candidate.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + prepared.candidate.tx_enabled = false; + regionSideEffectsPending = true; + } else { + RadioInterface::validateConfigRegion(prepared.candidate); + return false; + } + } + + RadioInterface::LoRaConfigNormalization validation = + RadioInterface::normalizeConfigLora(prepared.candidate, false, normalizationPrimaryChannel, candidateRadio); + if (!validation.valid) { + if (fromOthers) { + const RegionInfo *swapRegion = + prepared.candidate.use_preset + ? RadioInterface::regionSwapForPreset(prepared.candidate.region, prepared.candidate.modem_preset) + : nullptr; + if (swapRegion) { + prepared.candidate.region = swapRegion->code; + const RadioInterface::LoRaConfigNormalization swappedValidation = + RadioInterface::normalizeConfigLora(prepared.candidate, false, normalizationPrimaryChannel, candidateRadio); + if (swappedValidation.valid) { + delayDiagnostics(validation); + validation = swappedValidation; + } else { + RadioInterface::publishLoRaConfigDiagnostics(validation); + RadioInterface::publishLoRaConfigDiagnostics(swappedValidation); + LOG_WARN("Invalid LoRa config received from another node, rejecting changes"); + return false; + } + } else { + RadioInterface::publishLoRaConfigDiagnostics(validation); + LOG_WARN("Invalid LoRa config received from another node, rejecting changes"); + return false; + } + } else { + prepared.warnInvalidClientCorrection = true; + const uint32_t requestedChannelNum = prepared.candidate.channel_num; + delayDiagnostics(validation); + const RadioInterface::LoRaConfigNormalization corrected = + RadioInterface::normalizeConfigLora(prepared.candidate, true, normalizationPrimaryChannel, candidateRadio); + delayDiagnostics(corrected); + if (!corrected.valid) { + RadioInterface::publishLoRaConfigDiagnostics(corrected); + LOG_WARN("LoRa config cannot be corrected for this radio, rejecting changes"); + return false; + } + prepared.channelNumAutoCorrected = corrected.config.channel_num != requestedChannelNum; + prepared.candidate = corrected.config; + validation = corrected; + } + if (prepared.candidate.region != prepared.previous.region) + regionSideEffectsPending = true; + } + + const RegionInfo *candidateRegion = getRegion(prepared.candidate.region); + if (prepared.candidate.tx_power == 0 || + (prepared.candidate.tx_power > candidateRegion->powerLimit && !prospectiveLicensedOwner)) { + prepared.candidate.tx_power = candidateRegion->powerLimit; + } + if (prepared.candidate.tx_power == 0) + prepared.candidate.tx_power = 17; + + prepared.regionChanged = regionSideEffectsPending; + if (prepared.regionChanged) { + const RegionInfo *effectiveRegion = getRegion(prepared.candidate.region); +#ifdef REGULATORY_LORA_REGIONCODE + effectiveRegion = getRegion(REGULATORY_LORA_REGIONCODE); +#endif + float dutyCycle = effectiveRegion->dutyCycle; + if (effectiveRegion->code == meshtastic_Config_LoRaConfig_RegionCode_EU_866) + dutyCycle = IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_ROUTER, + meshtastic_Config_DeviceConfig_Role_ROUTER_LATE) + ? 10.0f + : 2.5f; + if (dutyCycle < 100) + prepared.candidate.ignore_mqtt = true; + + if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { + prepared.updateMqttRoot = true; + strncpy(prepared.mqttRootBefore, moduleConfig.mqtt.root, sizeof(prepared.mqttRootBefore) - 1); + } + prepared.saveWhat |= SEGMENT_MODULECONFIG; + } + +#if HAS_LORA_FEM + if (loraFEMInterface.isLnaCanControl()) { + prepared.setFemLna = true; + prepared.femLnaEnabled = prepared.candidate.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED; + } else if (prepared.candidate.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) { + prepared.candidate.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT; + prepared.warnFemNormalization = true; + } +#endif + +#if !MESHTASTIC_EXCLUDE_GPS + prepared.enableGps = prepared.candidate.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && gps != nullptr && + !gps->isEnabled() && config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED; +#endif + + prepared.warnPresetChange = prepared.candidate.modem_preset != prepared.previous.modem_preset; + const RadioInterface::LoRaConfigNormalization finalValidation = + RadioInterface::normalizeConfigLora(prepared.candidate, false, normalizationPrimaryChannel, candidateRadio); + prepared.updateFrequencySlotFlags = finalValidation.updatesFrequencySlotFlags; + prepared.usesDefaultFrequencySlot = finalValidation.usesDefaultFrequencySlot; + prepared.usesCustomChannelName = finalValidation.usesCustomChannelName; + if (prepared.candidate.override_frequency != 0) { + prepared.updateFrequencySlotFlags = true; + prepared.usesDefaultFrequencySlot = false; + prepared.usesCustomChannelName = RadioInterface::uses_custom_channel_name; + } + return true; +} + +bool AdminModule::requestLoRaConfig(const meshtastic_Config_LoRaConfig &incoming, bool fromOthers) { + const meshtastic_Config_LoRaConfig &previous = pendingLoRaConfig.saveWhat != 0 ? pendingLoRaConfig.candidate : config.lora; + const bool previousUsesDefault = pendingLoRaConfig.saveWhat != 0 ? pendingLoRaConfig.preserveDefaultFrequencySlot + : pendingChannelConfig.active ? pendingChannelConfig.preserveDefaultFrequencySlot + : RadioInterface::uses_default_frequency_slot; + auto candidate = incoming; + if (candidate.override_frequency == 0 && + (candidate.channel_num == 0 || (previousUsesDefault && candidate.channel_num == previous.channel_num))) + candidate.channel_num = 0; + + const bool accepted = requestLoRaConfig(candidate, fromOthers, prospectiveLicensedOwner(), StagedMenuLoRaTransition{}); + if (accepted) { + pendingLoRaConfig.preserveDefaultFrequencySlot = pendingLoRaConfig.usesDefaultFrequencySlot; + pendingChannelConfig.preserveDefaultFrequencySlot = pendingLoRaConfig.usesDefaultFrequencySlot; + } + return accepted; +} + +bool AdminModule::requestMenuLoRaConfig(const meshtastic_Config_LoRaConfig &incoming, MenuLoRaTransition transition) +{ + auto candidate = incoming; + StagedMenuLoRaTransition staged; + staged.type = transition; + bool candidateLicensed = owner.is_licensed; + + if (transition == MenuLoRaTransition::ENTER_LICENSED) { + candidateLicensed = true; + candidate.override_duty_cycle = true; + candidate.tx_enabled = false; + strncpy(staged.ham.call_sign, "N0CALL", sizeof(staged.ham.call_sign) - 1); + strncpy(staged.ham.short_name, "N0CL", sizeof(staged.ham.short_name)); + } else if (transition == MenuLoRaTransition::EXIT_LICENSED) { + candidateLicensed = false; + candidate.override_duty_cycle = false; + candidate.override_frequency = 0; + candidate.tx_enabled = true; + } + + const meshtastic_Config_LoRaConfig &previous = pendingLoRaConfig.saveWhat != 0 ? pendingLoRaConfig.candidate : config.lora; + const bool previousUsesDefault = pendingLoRaConfig.saveWhat != 0 ? pendingLoRaConfig.preserveDefaultFrequencySlot + : pendingChannelConfig.active ? pendingChannelConfig.preserveDefaultFrequencySlot + : RadioInterface::uses_default_frequency_slot; + if (candidate.override_frequency == 0 && + (candidate.channel_num == 0 || (previousUsesDefault && candidate.channel_num == previous.channel_num))) + candidate.channel_num = 0; + + const bool accepted = requestLoRaConfig(candidate, false, candidateLicensed, staged); + if (accepted) { + pendingLoRaConfig.preserveDefaultFrequencySlot = pendingLoRaConfig.usesDefaultFrequencySlot; + pendingChannelConfig.preserveDefaultFrequencySlot = pendingLoRaConfig.usesDefaultFrequencySlot; + } + return accepted; +} + +bool AdminModule::requestLoRaConfig(const meshtastic_Config_LoRaConfig &incoming, bool fromOthers, bool prospectiveLicensedOwner, + const StagedMenuLoRaTransition &transition) +{ + if (!service || !tryBeginLoRaConfigApply()) + return false; + + const PendingChannelConfig savedChannelConfig = pendingChannelConfig; + const PreparedLoRaConfig savedLoRaConfig = pendingLoRaConfig; + const StagedMenuLoRaTransition savedMenuTransition = pendingMenuLoRaTransition; + if (prospectiveLicensedOwner && (!owner.is_licensed || pendingOwnerConfig.active)) { + ensurePendingChannelConfig(); + pendingChannelConfig.licensedChannelsSanitized |= Channels::ensureLicensedOperation(pendingChannelConfig.candidate, true); + } + if (!hasOpenEditTransaction && pendingChannelConfig.active) + normalizePendingChannelPrimary(); + + PreparedLoRaConfig prepared; + if (!prepareLoRaConfig(incoming, fromOthers, prospectiveLicensedOwner, prepared)) { + pendingChannelConfig = savedChannelConfig; + cancelLoRaConfigApply(); + return false; + } + + pendingLoRaConfig = prepared; + pendingMenuLoRaTransition = transition; + if (pendingChannelConfig.active) { + pendingLoRaConfig.saveWhat |= SEGMENT_CHANNELS; + pendingLoRaConfig.candidateLicensed = prospectiveLicensedOwner; + } + if (transition.type == MenuLoRaTransition::ENTER_LICENSED) { + pendingLoRaConfig.generateLicensedIdentity = false; + pendingLoRaConfig.warnLicensedIdentityMigration = false; + pendingMenuLoRaTransition.ham.tx_power = prepared.candidate.tx_power; + pendingMenuLoRaTransition.ham.frequency = prepared.candidate.override_frequency; + } else if (pendingOwnerConfig.active && prospectiveLicensedOwner && + config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + pendingLoRaConfig.generateLicensedIdentity = true; + pendingLoRaConfig.warnLicensedIdentityMigration = licensedIdentityWillMigrate(); + pendingLoRaConfig.saveWhat |= SEGMENT_CONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; + } + if (hasOpenEditTransaction) { + editTransactionActivityMs = millis(); + cancelLoRaConfigApply(); + return true; + } + + if (!service->requestLoRaConfig(prepared.previous, prepared.candidate, LORA_CONFIG_APPLY_TIMEOUT_MS, + prepared.previousLicensed, prepared.candidateLicensed, + pendingChannelConfig.active ? &pendingChannelConfig.previousPrimary : nullptr, + pendingChannelConfig.active ? &pendingChannelConfig.candidatePrimary : nullptr)) { + pendingLoRaConfig = savedLoRaConfig; + pendingMenuLoRaTransition = savedMenuTransition; + pendingChannelConfig = savedChannelConfig; + cancelLoRaConfigApply(); + return false; + } + return true; +} + +bool AdminModule::tryBeginLoRaConfigApply() +{ + concurrency::LockGuard guard(&loRaSaveLock); + if (loRaSavesInProgress != 0 || loRaConfigApplyPending.load(std::memory_order_acquire)) + return false; + loRaConfigApplyPending.store(true, std::memory_order_release); + return true; +} + +void AdminModule::cancelLoRaConfigApply() +{ + finishLoRaConfigApplyAndFlushDeferred(); +} + +void AdminModule::finishLoRaConfigApplyAndFlushDeferred() +{ + int deferredSegments; + bool deferredReboot; + bool deferredNotify; + { + concurrency::LockGuard guard(&loRaSaveLock); + deferredSegments = deferredLoRaSaveSegments; + deferredReboot = deferredLoRaSaveReboot; + deferredNotify = deferredLoRaSaveNotify; + deferredLoRaSaveSegments = 0; + deferredLoRaSaveReboot = false; + deferredLoRaSaveNotify = false; + if (deferredSegments) + ++loRaSavesInProgress; + loRaConfigApplyPending.store(false, std::memory_order_release); + } + if (deferredSegments) { + persistChanges(deferredSegments, deferredReboot, deferredNotify); + concurrency::LockGuard guard(&loRaSaveLock); + assert(loRaSavesInProgress != 0); + --loRaSavesInProgress; + } +} + +void AdminModule::completeLoRaConfigApply(const RadioConfigApplyRequest &request) +{ + if (!loRaConfigApplyPending.load(std::memory_order_acquire)) + return; + + const RadioConfigApplyResult result = request.result.load(); + if (result == RadioConfigApplyResult::APPLIED) { + config.has_lora = true; + config.lora = pendingLoRaConfig.candidate; + int saveWhat = pendingLoRaConfig.saveWhat; + if (pendingChannelConfig.active) + publishPendingChannels(); + if (pendingOwnerConfig.active) { + owner = pendingOwnerConfig.candidate; + service->reloadOwner(false); + saveWhat |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; + } + saveWhat |= applyStagedMenuLoRaTransition(); + if (pendingLoRaConfig.updateFrequencySlotFlags) { + RadioInterface::uses_default_frequency_slot = pendingLoRaConfig.usesDefaultFrequencySlot; + RadioInterface::uses_custom_channel_name = pendingLoRaConfig.usesCustomChannelName; + } + if (pendingLoRaConfig.warnCodingRateNormalization) + LOG_WARN("Invalid coding_rate %d, setting to %d", pendingLoRaConfig.invalidCodingRate, LORA_CR_DEFAULT); + if (pendingLoRaConfig.warnSpreadFactorNormalization) + LOG_WARN("Invalid spread_factor %d, setting to %d", pendingLoRaConfig.invalidSpreadFactor, LORA_SF_DEFAULT); + if (pendingLoRaConfig.warnBandwidthNormalization) + LOG_WARN("Invalid bandwidth %d, setting to %d", pendingLoRaConfig.invalidBandwidth, config.lora.bandwidth); + if (pendingLoRaConfig.warnInvalidClientCorrection) + LOG_WARN("Invalid LoRa config received from client, using corrected values"); + RadioInterface::LoRaConfigNormalization delayedNormalization; + delayedNormalization.diagnosticCount = pendingLoRaConfig.delayedDiagnosticCount; + for (uint8_t i = 0; i < pendingLoRaConfig.delayedDiagnosticCount; ++i) + delayedNormalization.diagnostics[i] = pendingLoRaConfig.delayedDiagnostics[i]; + RadioInterface::publishLoRaConfigDiagnostics(delayedNormalization); + +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (pendingLoRaConfig.ensurePkiKeys && crypto && !owner.is_licensed) + crypto->ensurePkiKeys(config.security, owner); + if (pendingLoRaConfig.generateLicensedIdentity) { + if (pendingLoRaConfig.warnLicensedIdentityMigration) + sendWarning(licensedIdentityMigrationMessage); + nodeDB->generateCryptoKeyPair(); + if (pendingLoRaConfig.warnLicensedIdentityMigration) + nodeDB->licensedIdentityMigrationPending = false; + } +#endif + + if (pendingLoRaConfig.regionChanged) + initRegion(); + + if (pendingLoRaConfig.updateMqttRoot && + strncmp(moduleConfig.mqtt.root, pendingLoRaConfig.mqttRootBefore, sizeof(moduleConfig.mqtt.root)) == 0) { + snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name); + } + +#ifdef RF95_FAN_EN + digitalWrite(RF95_FAN_EN, pendingLoRaConfig.fanDisabled ? LOW ^ 0 : HIGH ^ 0); +#endif + +#if HAS_LORA_FEM + if (pendingLoRaConfig.setFemLna) + loraFEMInterface.setLNAEnable(pendingLoRaConfig.femLnaEnabled); + if (pendingLoRaConfig.warnFemNormalization) + LOG_WARN("FEM LNA mode configured but current FEM does not support LNA control; normalizing to NOT_PRESENT"); +#endif + +#if !MESHTASTIC_EXCLUDE_GPS + if (pendingLoRaConfig.enableGps && gps != nullptr && !gps->isEnabled()) + gps->enable(); +#endif + + saveChanges(saveWhat, false, false, true); + if (pendingChannelConfig.active) + queuePendingChannelWarnings(); + if (pendingLoRaConfig.warnPresetChange) + warnOnLoraPresetChange(pendingLoRaConfig.previous, pendingLoRaConfig.candidate); + if (!hasOpenEditTransaction) + flushChannelWarnings(); + } else { + if (owner.is_licensed && channels.ensureLicensedOperation()) { + channels.onConfigChanged(); + saveChanges(SEGMENT_CHANNELS, false, false, true); + warnLicensedMode(); + flushChannelWarnings(); + } + config.lora = pendingLoRaConfig.previous; + initRegion(); + if (result == RadioConfigApplyResult::ROLLBACK_FAILED) { + sendWarningAndLog("Radio configuration apply failed; radio recovery failed and transmission remains inhibited"); + } else if (result == RadioConfigApplyResult::INTERFACE_REPLACED) { + sendWarningAndLog( + "Radio configuration apply stopped because the radio interface changed; previous configuration retained"); + } else { + sendWarningAndLog("Radio configuration apply failed; previous configuration retained"); + } + } + + clearPreparedLoRaConfig(pendingLoRaConfig); + pendingMenuLoRaTransition = StagedMenuLoRaTransition{}; + pendingChannelConfig = PendingChannelConfig{}; + pendingOwnerConfig = PendingOwnerConfig{}; +} + +void AdminModule::finalizeLoRaConfigApply() +{ + if (!loRaConfigApplyPending.load(std::memory_order_acquire)) + return; + finishLoRaConfigApplyAndFlushDeferred(); +} + +bool AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) +{ + if (c.which_payload_variant == meshtastic_Config_lora_tag) + return requestLoRaConfig(c.payload_variant.lora, fromOthers); + auto changes = SEGMENT_CONFIG; auto existingRole = config.device.role; - bool isRegionUnset = (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET); bool requiresReboot = true; - bool loraPresetWarnPending = false; - meshtastic_Config_LoRaConfig pendingOldLora = {}, pendingNewLora = {}; switch (c.which_payload_variant) { case meshtastic_Config_device_tag: { @@ -1000,168 +1557,6 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) config.display = c.payload_variant.display; break; - case meshtastic_Config_lora_tag: { - // Wrap the entire case in a block to scope variables and avoid crossing initialization - auto oldLoraConfig = config.lora; - auto validatedLora = c.payload_variant.lora; - - LOG_INFO("Set config: LoRa"); - config.has_lora = true; - - if (validatedLora.coding_rate != clampCodingRate(validatedLora.coding_rate)) { - LOG_WARN("Invalid coding_rate %d, setting to %d", validatedLora.coding_rate, LORA_CR_DEFAULT); - validatedLora.coding_rate = LORA_CR_DEFAULT; - } - - if (validatedLora.spread_factor != clampSpreadFactor(validatedLora.spread_factor)) { - LOG_WARN("Invalid spread_factor %d, setting to %d", validatedLora.spread_factor, LORA_SF_DEFAULT); - validatedLora.spread_factor = LORA_SF_DEFAULT; - } - - // A custom (non-preset) config that leaves bandwidth at its proto zero-value otherwise slips - // through validateConfigLora() and persists as 0, while the radio silently falls back to the - // default (config.lora.bandwidth then reads back 0 even though the radio runs at 250kHz). - // Coerce it here like coding_rate/spread_factor so the stored config matches the radio. In - // preset mode bandwidth 0 is expected (the preset supplies it), so leave it untouched. - const uint16_t clampedBandwidth = clampBandwidthCode(validatedLora.bandwidth); - if (!validatedLora.use_preset && validatedLora.bandwidth != clampedBandwidth) { - LOG_WARN("Invalid bandwidth %d, setting to %d", validatedLora.bandwidth, clampedBandwidth); - validatedLora.bandwidth = clampedBandwidth; - } - - // 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 it is valid for e.g. licensing conditions and if the lora config is valid - if (RadioInterface::validateConfigRegion(validatedLora) && RadioInterface::validateConfigLora(validatedLora)) { - // 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 (crypto && !owner.is_licensed) { - crypto->ensurePkiKeys(config.security, owner); - } -#endif - // new region is valid and we're coming from an unset region, so enable tx - 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; - } - // Ensure initRegion() uses the newly validated region - config.lora.region = validatedLora.region; -#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (owner.is_licensed && isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { - const bool identityWillMigrate = licensedIdentityWillMigrate(); - if (identityWillMigrate) - sendWarning(licensedIdentityMigrationMessage); - nodeDB->generateCryptoKeyPair(); - changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; - if (identityWillMigrate) - nodeDB->licensedIdentityMigrationPending = false; - } -#endif - 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; - } 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) { - // 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. - } - - // All LoRa radio changes apply live via configChanged observer → reconfigure(). - // reconfigure() puts the radio in standby, reprograms all modem parameters, and restarts receive. - requiresReboot = false; - -#if defined(ARCH_PORTDUINO) - // If running on portduino and using SimRadio, do not require reboot - if (SimRadio::instance) { - requiresReboot = false; - } -#endif - -#ifdef RF95_FAN_EN - // Turn PA off if disabled by config - if (c.payload_variant.lora.pa_fan_disabled) { - digitalWrite(RF95_FAN_EN, LOW ^ 0); - } else { - digitalWrite(RF95_FAN_EN, HIGH ^ 0); - } -#endif - -#if HAS_LORA_FEM - // Apply FEM LNA mode from config (only meaningful on hardware that supports it) - // Note that a rejected lora config will revert this as well. - if (loraFEMInterface.isLnaCanControl()) { - loraFEMInterface.setLNAEnable(validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED); - } 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 !MESHTASTIC_EXCLUDE_GPS - // Enable gps if it was previously disabled due to region not being set - if (!requiresReboot && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && gps != nullptr && - !gps->isEnabled() && config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { - gps->enable(); - } -#endif - - config.lora = validatedLora; // Finally, return the validated config back to the main config - if (validatedLora.modem_preset != oldLoraConfig.modem_preset) { - pendingOldLora = oldLoraConfig; - pendingNewLora = validatedLora; - loraPresetWarnPending = true; - } - - break; - } case meshtastic_Config_bluetooth_tag: LOG_INFO("Set config: Bluetooth"); config.has_bluetooth = true; @@ -1231,11 +1626,10 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) } // end of switch case which_payload_variant saveChanges(changes, requiresReboot); - if (loraPresetWarnPending) - warnOnLoraPresetChange(pendingOldLora, pendingNewLora); // Inside an edit transaction the queued warnings are flushed once at commit; otherwise emit now. if (!hasOpenEditTransaction) flushChannelWarnings(); + return true; } // end of handleSetConfig bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) @@ -1447,37 +1841,162 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) return true; } -void AdminModule::handleSetChannel(const meshtastic_Channel &cc) +bool AdminModule::pendingChannelNeedsRadioApply() const { - channels.setChannel(cc); - if (channels.ensureLicensedOperation()) { + return pendingChannelConfig.active && + (pendingChannelConfig.previousPrimaryIndex != pendingChannelConfig.candidatePrimaryIndex || + memcmp(&pendingChannelConfig.previousPrimary, &pendingChannelConfig.candidatePrimary, + sizeof(pendingChannelConfig.previousPrimary)) != 0); +} + +void AdminModule::ensurePendingChannelConfig() +{ + if (pendingChannelConfig.active) + return; + + pendingChannelConfig.active = true; + pendingChannelConfig.candidate = channelFile; + pendingChannelConfig.previousPrimaryIndex = channels.getPrimaryIndex(); + pendingChannelConfig.candidatePrimaryIndex = channels.getPrimaryIndex(); + pendingChannelConfig.previousPrimary = channels.getPrimary(); + pendingChannelConfig.candidatePrimary = channels.getPrimary(); + pendingChannelConfig.preserveDefaultFrequencySlot = pendingLoRaConfig.saveWhat != 0 + ? pendingLoRaConfig.preserveDefaultFrequencySlot + : RadioInterface::uses_default_frequency_slot; +} + +void AdminModule::normalizePendingChannelPrimary() +{ + if (!pendingChannelConfig.active) + return; + + const auto primary = pendingChannelConfig.candidate.channels[pendingChannelConfig.candidatePrimaryIndex]; + pendingChannelConfig.candidatePrimaryIndex = + Channels::setChannelInFile(pendingChannelConfig.candidate, primary, pendingChannelConfig.candidatePrimaryIndex); + pendingChannelConfig.licensedChannelsSanitized |= + Channels::ensureLicensedOperation(pendingChannelConfig.candidate, prospectiveLicensedOwner()); + pendingChannelConfig.candidatePrimary = + pendingChannelConfig.candidate.channels[pendingChannelConfig.candidatePrimaryIndex].settings; + if (pendingLoRaConfig.saveWhat != 0) { + if (pendingChannelConfig.preserveDefaultFrequencySlot && !pendingLoRaConfig.channelNumAutoCorrected) + pendingLoRaConfig.candidate.channel_num = 0; + if (pendingLoRaConfig.channelNumAutoCorrected) { + auto correctedCandidate = pendingLoRaConfig.candidate; + correctedCandidate.channel_num = UINT16_MAX; + const auto corrected = RadioInterface::normalizeConfigLora( + correctedCandidate, true, &pendingChannelConfig.candidatePrimary, router ? router->getRadioIface() : nullptr); + if (corrected.valid) + pendingLoRaConfig.candidate.channel_num = corrected.config.channel_num; + } + const auto normalization = + RadioInterface::normalizeConfigLora(pendingLoRaConfig.candidate, false, &pendingChannelConfig.candidatePrimary, + router ? router->getRadioIface() : nullptr); + pendingLoRaConfig.updateFrequencySlotFlags = normalization.updatesFrequencySlotFlags; + pendingLoRaConfig.usesDefaultFrequencySlot = normalization.usesDefaultFrequencySlot; + pendingLoRaConfig.usesCustomChannelName = normalization.usesCustomChannelName; + } +} + +bool AdminModule::prospectiveLicensedOwner() const +{ + return pendingOwnerConfig.active ? pendingOwnerConfig.candidate.is_licensed : owner.is_licensed; +} + +void AdminModule::publishPendingChannels() +{ + channelFile = pendingChannelConfig.candidate; + channels.onConfigChanged(); +} + +void AdminModule::queuePendingChannelWarnings() +{ + if (pendingChannelConfig.licensedChannelsSanitized) warnLicensedMode(); + if (pendingChannelConfig.precisionClamped) + sendWarning(publicChannelPrecisionMessage); + for (uint8_t i = 0; i < MAX_NUM_CHANNELS; ++i) { + if (pendingChannelConfig.changedChannels & (1u << i)) + warnOnChannelSet(pendingChannelConfig.candidate.channels[i]); } - // Refresh derived state (primaryIndex in particular) BEFORE the precision clamp below. usesPublicKey() - // resolves a secondary channel's key against the primary, so it must see the post-update primaryIndex; - // running the clamp first could evaluate secondaries against the previous primary and skip the clamp/warning. - channels.onConfigChanged(); // tell the radios about this change - - // Persist the public-key precision clamp for all channels that may be affected (e.g. secondaries - // that inherit a now-public primary key) and warn the client once if anything was coarsened. - bool clamped = false; - for (uint8_t i = 0; i < channels.getNumChannels(); i++) { - meshtastic_Channel &ch = channels.getByIndex(i); - if (ch.role == meshtastic_Channel_Role_DISABLED || !ch.settings.has_module_settings) +} + +bool AdminModule::handleSetChannel(const meshtastic_Channel &cc) +{ + if (loRaConfigApplyPending.load(std::memory_order_acquire)) { + sendWarningAndLog("Radio configuration apply is busy; channel change was not applied"); + return false; + } + + ensurePendingChannelConfig(); + + pendingChannelConfig.candidatePrimaryIndex = + Channels::setChannelInFile(pendingChannelConfig.candidate, cc, pendingChannelConfig.candidatePrimaryIndex, false); + pendingChannelConfig.changedChannels |= 1u << cc.index; + pendingChannelConfig.licensedChannelsSanitized |= + Channels::ensureLicensedOperation(pendingChannelConfig.candidate, prospectiveLicensedOwner()); + + for (uint8_t i = 0; i < pendingChannelConfig.candidate.channels_count; ++i) { + auto &channel = pendingChannelConfig.candidate.channels[i]; + if (channel.role == meshtastic_Channel_Role_DISABLED || !channel.settings.has_module_settings) continue; - uint32_t allowed = getPositionPrecisionForChannel(i); - if (allowed != ch.settings.module_settings.position_precision) { - ch.settings.module_settings.position_precision = allowed; - clamped = true; + uint32_t allowed = channel.settings.module_settings.position_precision; + if (allowed > MAX_POSITION_PRECISION_PUBLIC_KEY && channelFileUsesPublicKey(pendingChannelConfig.candidate, i)) + allowed = MAX_POSITION_PRECISION_PUBLIC_KEY; + if (allowed != channel.settings.module_settings.position_precision) { + channel.settings.module_settings.position_precision = allowed; + pendingChannelConfig.precisionClamped = true; } } - if (clamped) - sendWarning(publicChannelPrecisionMessage); - saveChanges(SEGMENT_CHANNELS, false); - warnOnChannelSet(channels.getByIndex(cc.index)); // passes the saved channel - // Inside an edit transaction the queued warnings are flushed once at commit; otherwise emit now. - if (!hasOpenEditTransaction) + + if (pendingLoRaConfig.saveWhat == 0) { + pendingLoRaConfig.previous = config.lora; + pendingLoRaConfig.candidate = config.lora; + pendingLoRaConfig.previousLicensed = owner.is_licensed; + pendingLoRaConfig.candidateLicensed = prospectiveLicensedOwner(); + pendingLoRaConfig.preserveDefaultFrequencySlot = pendingChannelConfig.preserveDefaultFrequencySlot; + } + pendingLoRaConfig.saveWhat |= SEGMENT_CHANNELS; + + const auto normalization = RadioInterface::normalizeConfigLora( + pendingLoRaConfig.candidate, false, &pendingChannelConfig.candidatePrimary, router ? router->getRadioIface() : nullptr); + pendingLoRaConfig.updateFrequencySlotFlags = normalization.updatesFrequencySlotFlags; + pendingLoRaConfig.usesDefaultFrequencySlot = normalization.usesDefaultFrequencySlot; + pendingLoRaConfig.usesCustomChannelName = normalization.usesCustomChannelName; + + if (hasOpenEditTransaction) { + deferredEditSegments |= SEGMENT_CHANNELS; + editTransactionActivityMs = millis(); + return true; + } + + normalizePendingChannelPrimary(); + + if (!pendingChannelNeedsRadioApply()) { + publishPendingChannels(); + saveChanges(SEGMENT_CHANNELS, false, false); + queuePendingChannelWarnings(); flushChannelWarnings(); + pendingChannelConfig = PendingChannelConfig{}; + clearPreparedLoRaConfig(pendingLoRaConfig); + return true; + } + + if (!service || !tryBeginLoRaConfigApply()) { + pendingChannelConfig = PendingChannelConfig{}; + clearPreparedLoRaConfig(pendingLoRaConfig); + sendWarningAndLog("Radio configuration apply is busy; channel change was not applied"); + return false; + } + if (!service->requestLoRaConfig(pendingLoRaConfig.previous, pendingLoRaConfig.candidate, LORA_CONFIG_APPLY_TIMEOUT_MS, + pendingLoRaConfig.previousLicensed, pendingLoRaConfig.candidateLicensed, + &pendingChannelConfig.previousPrimary, &pendingChannelConfig.candidatePrimary)) { + pendingChannelConfig = PendingChannelConfig{}; + clearPreparedLoRaConfig(pendingLoRaConfig); + cancelLoRaConfigApply(); + sendWarningAndLog("Radio configuration apply could not be queued; previous channel retained"); + return false; + } + return true; } /** @@ -1844,7 +2363,9 @@ void AdminModule::handleGetChannel(const meshtastic_MeshPacket &req, uint32_t ch if (req.decoded.want_response) { // We create the reply here meshtastic_AdminMessage r = meshtastic_AdminMessage_init_default; - r.get_channel_response = channels.getByIndex(channelIndex); + r.get_channel_response = hasOpenEditTransaction && pendingChannelConfig.active + ? pendingChannelConfig.candidate.channels[channelIndex] + : channels.getByIndex(channelIndex); r.which_payload_variant = meshtastic_AdminMessage_get_channel_response_tag; setPassKey(&r); myReply = allocDataProtobuf(r); @@ -1881,29 +2402,94 @@ void AdminModule::reboot(int32_t seconds) // Without this, a commit that never arrives leaves the transaction open forever and every later // config write from any client is applied, acknowledged, and then never saved. -void AdminModule::expireStaleEditTransaction() +bool AdminModule::expireStaleEditTransaction() { if (!hasOpenEditTransaction || Throttle::isWithinTimespanMs(editTransactionActivityMs, EDIT_TRANSACTION_IDLE_MS)) - return; + return true; + + normalizePendingChannelPrimary(); + const bool hasStagedRadioConfig = (pendingLoRaConfig.saveWhat & ~SEGMENT_CHANNELS) != 0 || pendingOwnerConfig.active; + const bool requiresRadioApply = hasStagedRadioConfig || pendingChannelNeedsRadioApply(); + if (requiresRadioApply && (!service || !tryBeginLoRaConfigApply())) { + sendWarningAndLog("Radio configuration apply is busy; abandoned edits remain staged for retry"); + return false; + } LOG_WARN("Edit transaction abandoned for %us; committing what it applied", EDIT_TRANSACTION_IDLE_MS / 1000); hasOpenEditTransaction = false; int segments = deferredEditSegments; deferredEditSegments = 0; - // No reboot: the settings are already live in RAM and the client that would expect one is gone. - if (segments) - saveChanges(segments, false); - flushChannelWarnings(); + if (requiresRadioApply) { + const int unrelatedSegments = pendingChannelConfig.active ? segments & ~SEGMENT_CHANNELS : segments; + if (!service->requestLoRaConfig(pendingLoRaConfig.previous, pendingLoRaConfig.candidate, LORA_CONFIG_APPLY_TIMEOUT_MS, + pendingLoRaConfig.previousLicensed, pendingLoRaConfig.candidateLicensed, + pendingChannelConfig.active ? &pendingChannelConfig.previousPrimary : nullptr, + pendingChannelConfig.active ? &pendingChannelConfig.candidatePrimary : nullptr)) { + pendingChannelConfig = PendingChannelConfig{}; + pendingOwnerConfig = PendingOwnerConfig{}; + clearPreparedLoRaConfig(pendingLoRaConfig); + pendingMenuLoRaTransition = StagedMenuLoRaTransition{}; + cancelLoRaConfigApply(); + sendWarningAndLog("Radio configuration apply could not be queued; abandoned edits were discarded"); + } + if (unrelatedSegments) + saveChanges(unrelatedSegments, false); + } else { + if (pendingChannelConfig.active) { + publishPendingChannels(); + saveChanges(SEGMENT_CHANNELS, false, false); + queuePendingChannelWarnings(); + pendingChannelConfig = PendingChannelConfig{}; + clearPreparedLoRaConfig(pendingLoRaConfig); + } + segments &= ~SEGMENT_CHANNELS; + if (segments) + saveChanges(segments, false); + flushChannelWarnings(); + } + return true; } -void AdminModule::saveChanges(int saveWhat, bool shouldReboot) +void AdminModule::saveChanges(int saveWhat, bool shouldReboot, bool notifyConfigChange, bool radioApplyCompletion) { #ifdef PIO_UNIT_TESTING lastSaveWhatForTest = saveWhat; #endif + { + concurrency::LockGuard guard(&loRaSaveLock); + if (loRaConfigApplyPending.load(std::memory_order_acquire) && !radioApplyCompletion) { + LOG_INFO("Delay save of changes to disk until LoRa configuration apply completes"); + deferredLoRaSaveSegments |= saveWhat; + deferredLoRaSaveReboot = deferredLoRaSaveReboot || shouldReboot; + deferredLoRaSaveNotify = deferredLoRaSaveNotify || notifyConfigChange; + return; + } + ++loRaSavesInProgress; + } + persistChanges(saveWhat, shouldReboot, notifyConfigChange); + { + concurrency::LockGuard guard(&loRaSaveLock); + assert(loRaSavesInProgress != 0); + --loRaSavesInProgress; + } +} + +void AdminModule::persistChanges(int saveWhat, bool shouldReboot, bool notifyConfigChange) +{ if (!hasOpenEditTransaction) { LOG_INFO("Save changes to disk"); - service->reloadConfig(saveWhat); // Calls saveToDisk among other things +#ifdef PIO_UNIT_TESTING + persistedSaveWhatForTest |= saveWhat; + persistenceCountForTest++; + persistedLoRaForTest = config.lora; +#endif + if (notifyConfigChange) { + service->reloadConfig(saveWhat); + } else { + if (saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS)) + nodeDB->resetRadioConfig(); + nodeDB->saveToDisk(saveWhat); + } } else { LOG_INFO("Delay save of changes to disk until the open transaction is committed"); editTransactionActivityMs = millis(); // still in use, so not the abandoned kind we time out @@ -1921,10 +2507,9 @@ void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uic #endif } -void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) +bool AdminModule::validateHamParameters(const meshtastic_HamParameters ¶ms) const { - // Validate ham parameters before setting since this would bypass validation in the owner struct - const char *fieldsToCheck[] = {p.call_sign, p.short_name}; + const char *fieldsToCheck[] = {params.call_sign, params.short_name}; const char *fieldNames[] = {"call_sign", "short_name"}; for (int i = 0; i < 2; i++) { if (*fieldsToCheck[i]) { @@ -1933,22 +2518,25 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) start++; if (*start == '\0') { LOG_WARN("Rejected ham %s: must contain at least 1 non-whitespace character", fieldNames[i]); - return; + return false; } } } + return true; +} - // Set call sign and override lora limitations for licensed use - strncpy(owner.long_name, p.call_sign, sizeof(owner.long_name)); +int AdminModule::applyEnterLicensedMode(const meshtastic_HamParameters ¶ms) +{ + strncpy(owner.long_name, params.call_sign, sizeof(owner.long_name)); owner.long_name[sizeof(owner.long_name) - 1] = '\0'; sanitizeUtf8(owner.long_name, sizeof(owner.long_name)); - strncpy(owner.short_name, p.short_name, sizeof(owner.short_name)); + strncpy(owner.short_name, params.short_name, sizeof(owner.short_name)); owner.short_name[sizeof(owner.short_name) - 1] = '\0'; sanitizeUtf8(owner.short_name, sizeof(owner.short_name)); owner.is_licensed = true; config.lora.override_duty_cycle = true; - config.lora.tx_power = p.tx_power; - config.lora.override_frequency = p.frequency; + config.lora.tx_power = params.tx_power; + config.lora.override_frequency = params.frequency; // Set node info broadcast interval to 10 minutes // For FCC minimum call-sign announcement config.device.node_info_broadcast_secs = 600; @@ -1967,17 +2555,46 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) } #endif - if (channels.ensureLicensedOperation()) { - warnLicensedMode(); - } - channels.onConfigChanged(); - - if (strcmp(p.call_sign, "N0CALL") == 0) { + if (strcmp(params.call_sign, "N0CALL") == 0) { config.lora.tx_enabled = false; } service->reloadOwner(false); - saveChanges(SEGMENT_CONFIG | SEGMENT_NODEDATABASE | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS); + return SEGMENT_CONFIG | SEGMENT_NODEDATABASE | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS; +} + +int AdminModule::applyStagedMenuLoRaTransition() +{ + const StagedMenuLoRaTransition staged = pendingMenuLoRaTransition; + pendingMenuLoRaTransition = StagedMenuLoRaTransition{}; + + if (staged.type == MenuLoRaTransition::ENTER_LICENSED) + return applyEnterLicensedMode(staged.ham); + if (staged.type == MenuLoRaTransition::EXIT_LICENSED) { + owner.is_licensed = false; + service->reloadOwner(false); + return SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; + } + return 0; +} + +void AdminModule::handleSetHamMode(const meshtastic_HamParameters ¶ms) +{ + if (!validateHamParameters(params)) + return; + + meshtastic_Config_LoRaConfig candidate = config.lora; + candidate.override_duty_cycle = true; + candidate.tx_power = params.tx_power; + candidate.override_frequency = params.frequency; + if (strcmp(params.call_sign, "N0CALL") == 0) + candidate.tx_enabled = false; + + StagedMenuLoRaTransition staged; + staged.type = MenuLoRaTransition::ENTER_LICENSED; + staged.ham = params; + if (!requestLoRaConfig(candidate, false, true, staged)) + sendWarningAndLog("Licensed mode radio configuration could not be queued; previous configuration retained"); } AdminModule::AdminModule() : ProtobufModule("Admin", meshtastic_PortNum_ADMIN_APP, &meshtastic_AdminMessage_msg) @@ -2436,8 +3053,8 @@ void AdminModule::warnOnChannelSet(const meshtastic_Channel &cc) /** * @brief Scan all channels for preset-name conflicts after a modem preset change is committed. * - * Called from handleSetConfig() after the LoRa config has been saved, and only when - * modem_preset actually changed (rejected configs are never passed here). For every + * Called after the LoRa config has been applied and its save has been scheduled, and only + * when modem_preset actually changed (rejected configs are never passed here). For every * named, non-disabled channel two checks are performed: * * - Name matches the *old* preset (case-insensitive, spaces stripped): the channel diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index b8e8c59adec..e7ec1a9190e 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -1,8 +1,13 @@ #pragma once + +#include #ifdef ESP_PLATFORM #include #endif #include "ProtobufModule.h" +#include "concurrency/Lock.h" +#include "mesh/RadioConfigApply.h" +#include "mesh/RadioInterface.h" #include "meshUtils.h" #include #if HAS_WIFI @@ -26,6 +31,8 @@ class AdminModule : public ProtobufModule, public Obser friend class AdminModuleTestShim; // test/support/AdminModuleTestShim.h - native tests reach the private handlers/state public: + enum class MenuLoRaTransition : uint8_t { NONE, ENTER_LICENSED, EXIT_LICENSED }; + /** Constructor * name is for debugging output */ @@ -46,16 +53,19 @@ class AdminModule : public ProtobufModule, public Obser uint32_t editTransactionActivityMs = 0; // millis() of the last save this transaction deferred int deferredEditSegments = 0; // segments that transaction has touched but not yet saved /// Retire an open edit transaction whose client stopped talking, persisting what it applied. - void expireStaleEditTransaction(); + bool expireStaleEditTransaction(); #ifdef PIO_UNIT_TESTING int lastSaveWhatForTest = 0; + int persistedSaveWhatForTest = 0; + uint32_t persistenceCountForTest = 0; + meshtastic_Config_LoRaConfig persistedLoRaForTest = meshtastic_Config_LoRaConfig_init_zero; #endif uint8_t session_passkey[8] = {0}; uint32_t session_time = 0; // millis() when the current session passkey was issued bool sessionPasskeyValid = false; // separate flag: millis() 0 at boot is a valid issue time - void saveChanges(int saveWhat, bool shouldReboot = true); + void saveChanges(int saveWhat, bool shouldReboot = true, bool notifyConfigChange = true, bool radioApplyCompletion = false); /** * Getters @@ -80,11 +90,11 @@ class AdminModule : public ProtobufModule, public Obser /** * Setters */ - void handleSetOwner(const meshtastic_User &o); - void handleSetChannel(const meshtastic_Channel &cc); + bool handleSetOwner(const meshtastic_User &o); + bool handleSetChannel(const meshtastic_Channel &cc); protected: - void handleSetConfig(const meshtastic_Config &c, bool fromOthers); + bool handleSetConfig(const meshtastic_Config &c, bool fromOthers); #ifdef PIO_UNIT_TESTING protected: @@ -96,6 +106,11 @@ class AdminModule : public ProtobufModule, public Obser public: void handleSetHamMode(const meshtastic_HamParameters &req); + bool requestLoRaConfig(const meshtastic_Config_LoRaConfig &incoming, bool fromOthers); + bool requestMenuLoRaConfig(const meshtastic_Config_LoRaConfig &incoming, + MenuLoRaTransition transition = MenuLoRaTransition::NONE); + void completeLoRaConfigApply(const RadioConfigApplyRequest &request); + void finalizeLoRaConfigApply(); /// Note an admin request leaving this node for a remote, so that remote's response is /// accepted. Called from the client-to-mesh path (MeshService::handleToRadio). @@ -137,6 +152,79 @@ class AdminModule : public ProtobufModule, public Obser bool messageIsResponse(const meshtastic_AdminMessage *r); bool messageIsRequest(const meshtastic_AdminMessage *r); + struct PreparedLoRaConfig { + static constexpr size_t MAX_DELAYED_DIAGNOSTICS = RadioInterface::MAX_LORA_CONFIG_DIAGNOSTICS; + meshtastic_Config_LoRaConfig previous = meshtastic_Config_LoRaConfig_init_zero; + meshtastic_Config_LoRaConfig candidate = meshtastic_Config_LoRaConfig_init_zero; + RadioInterface::LoRaConfigDiagnostic delayedDiagnostics[MAX_DELAYED_DIAGNOSTICS] = {}; + int saveWhat = 0; + char mqttRootBefore[32] = {}; + uint8_t delayedDiagnosticCount = 0; + uint8_t invalidCodingRate = 0; + uint8_t invalidSpreadFactor = 0; + uint16_t invalidBandwidth = 0; + bool regionChanged = false; + bool channelNumAutoCorrected = false; + bool preserveDefaultFrequencySlot = false; + bool updateFrequencySlotFlags = false; + bool usesDefaultFrequencySlot = false; + bool usesCustomChannelName = false; + bool warnCodingRateNormalization = false; + bool warnSpreadFactorNormalization = false; + bool warnBandwidthNormalization = false; + bool warnInvalidClientCorrection = false; + bool ensurePkiKeys = false; + bool generateLicensedIdentity = false; + bool warnLicensedIdentityMigration = false; + bool updateMqttRoot = false; + bool enableGps = false; + bool setFemLna = false; + bool femLnaEnabled = false; + bool warnFemNormalization = false; + bool warnPresetChange = false; + bool fanDisabled = false; + bool previousLicensed = false; + bool candidateLicensed = false; + }; + struct StagedMenuLoRaTransition { + MenuLoRaTransition type = MenuLoRaTransition::NONE; + meshtastic_HamParameters ham = meshtastic_HamParameters_init_zero; + }; + struct PendingChannelConfig { + meshtastic_ChannelFile candidate = meshtastic_ChannelFile_init_zero; + meshtastic_ChannelSettings previousPrimary = meshtastic_ChannelSettings_init_zero; + meshtastic_ChannelSettings candidatePrimary = meshtastic_ChannelSettings_init_zero; + uint32_t changedChannels = 0; + uint8_t previousPrimaryIndex = 0; + uint8_t candidatePrimaryIndex = 0; + bool licensedChannelsSanitized = false; + bool precisionClamped = false; + bool preserveDefaultFrequencySlot = false; + bool active = false; + }; + struct PendingOwnerConfig { + meshtastic_User previous = meshtastic_User_init_zero; + meshtastic_User candidate = meshtastic_User_init_zero; + bool active = false; + }; + static void clearPreparedLoRaConfig(PreparedLoRaConfig &prepared); + bool prepareLoRaConfig(const meshtastic_Config_LoRaConfig &incoming, bool fromOthers, bool prospectiveLicensedOwner, + PreparedLoRaConfig &prepared); + bool requestLoRaConfig(const meshtastic_Config_LoRaConfig &incoming, bool fromOthers, bool prospectiveLicensedOwner, + const StagedMenuLoRaTransition &transition); + bool tryBeginLoRaConfigApply(); + void cancelLoRaConfigApply(); + void finishLoRaConfigApplyAndFlushDeferred(); + bool pendingChannelNeedsRadioApply() const; + void ensurePendingChannelConfig(); + void normalizePendingChannelPrimary(); + bool prospectiveLicensedOwner() const; + void publishPendingChannels(); + void queuePendingChannelWarnings(); + void persistChanges(int saveWhat, bool shouldReboot, bool notifyConfigChange); + bool validateHamParameters(const meshtastic_HamParameters ¶ms) const; + int applyEnterLicensedMode(const meshtastic_HamParameters ¶ms); + int applyStagedMenuLoRaTransition(); void sendWarning(const char *format, ...) __attribute__((format(printf, 2, 3))); void sendWarningAndLog(const char *format, ...) __attribute__((format(printf, 2, 3))); void warnOnLoraPresetChange(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora); @@ -160,6 +248,18 @@ class AdminModule : public ProtobufModule, public Obser bool pendingWarningNameIssue = false; // any queued warning was about a channel name bool pendingWarningPskIssue = false; // any queued warning was about a PSK bool pendingLicenseWarning = false; // a licensed-mode notice is queued for this transaction + + static constexpr uint32_t LORA_CONFIG_APPLY_TIMEOUT_MS = 60 * 1000; + PreparedLoRaConfig pendingLoRaConfig{}; + StagedMenuLoRaTransition pendingMenuLoRaTransition; + PendingChannelConfig pendingChannelConfig; + PendingOwnerConfig pendingOwnerConfig; + std::atomic loRaConfigApplyPending{false}; + concurrency::Lock loRaSaveLock; + uint32_t loRaSavesInProgress = 0; + int deferredLoRaSaveSegments = 0; + bool deferredLoRaSaveReboot = false; + bool deferredLoRaSaveNotify = false; }; static constexpr const char *licensedModeMessage = diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index cc5c6e807b9..3e1fa6b1af0 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -5,6 +5,8 @@ #include "RadioInterface.h" #include "Router.h" #include "TransmitHistory.h" +#include "concurrency/Lock.h" +#include "concurrency/LockGuard.h" #include "configuration.h" #include "gps/RTC.h" #include "main.h" @@ -17,7 +19,11 @@ uint16_t MeshBeaconModule::originalLoraChannel; meshtastic_Config_LoRaConfig_RegionCode MeshBeaconModule::originalRegion; meshtastic_ChannelSettings MeshBeaconModule::originalPrimaryChannel; -static MeshBeaconModule_TargetRadioSettings targetRadioSettings[8]; +static MeshBeaconModule_TargetRadioSettings targetRadioSettings[MAX_TX_QUEUE + 2]; +static std::atomic radioConfigTemporary{false}; +static std::atomic radioRestorePending{false}; +static bool radioConfigTransitionInProgress = false; +static concurrency::Lock radioConfigLock; static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset *preset, uint16_t *slot, bool *legacyHopOverride = nullptr, @@ -58,13 +64,40 @@ MeshBeaconModule::MeshBeaconModule() originalPrimaryChannel = channels.getPrimary(); } -void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset preset, +bool MeshBeaconModule::radioConfigIsTemporary() +{ + return radioConfigTemporary.load(std::memory_order_acquire); +} + +bool MeshBeaconModule::radioRestoreIsPending() +{ + return radioRestorePending.load(std::memory_order_acquire); +} + +bool MeshBeaconModule::getHomeRadioConfig(meshtastic_Config_LoRaConfig &lora, meshtastic_ChannelSettings *primary) +{ + concurrency::LockGuard guard(&radioConfigLock); + lora = config.lora; + if (primary) + *primary = channels.getPrimary(); + if (!radioConfigTemporary.load(std::memory_order_acquire)) + return false; + + lora.modem_preset = originalModemPreset; + lora.channel_num = originalLoraChannel; + lora.region = originalRegion; + if (primary) + *primary = originalPrimaryChannel; + return true; +} + +bool MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset preset, uint16_t slot, bool legacyHopOverride, meshtastic_Config_LoRaConfig_RegionCode region, bool has_channel, const meshtastic_ChannelSettings *channel) { if (!p) - return; + return false; MeshBeaconModule_TargetRadioSettings *target = nullptr; for (auto &entry : targetRadioSettings) { if (entry.inUse && entry.id == p->id) { @@ -75,7 +108,7 @@ void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, me target = &entry; } if (!target) - target = &targetRadioSettings[0]; + return false; target->inUse = true; target->id = p->id; target->preset = preset; @@ -85,6 +118,7 @@ void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, me target->has_channel = has_channel; if (has_channel && channel) target->channel = *channel; + return true; } bool MeshBeaconModule::hasTargetRadioSettings(const meshtastic_MeshPacket *p) @@ -96,15 +130,20 @@ void MeshBeaconModule::clearTargetRadioSettings(const meshtastic_MeshPacket *p) { if (!p) return; + clearTargetRadioSettings(p->id); +} + +void MeshBeaconModule::clearTargetRadioSettings(PacketId id) +{ for (auto &entry : targetRadioSettings) { - if (entry.inUse && entry.id == p->id) { + if (entry.inUse && entry.id == id) { entry.inUse = false; return; } } } -bool MeshBeaconModule::beaconTxConfigInvalid(const meshtastic_MeshPacket *p) +bool MeshBeaconModule::beaconTxConfigInvalid(const meshtastic_MeshPacket *p, RadioInterface *iface) { meshtastic_Config_LoRaConfig_ModemPreset preset; meshtastic_Config_LoRaConfig_RegionCode sidecarRegion = meshtastic_Config_LoRaConfig_RegionCode_UNSET; @@ -126,7 +165,7 @@ bool MeshBeaconModule::beaconTxConfigInvalid(const meshtastic_MeshPacket *p) probe.use_preset = true; probe.modem_preset = preset; probe.region = region; - return !RadioInterface::validateConfigLora(probe); + return !RadioInterface::normalizeConfigLora(probe, false, nullptr, iface).valid; } meshtastic_ChannelSettings MeshBeaconModule::beaconChannelSettings(const meshtastic_ChannelSettings &base, @@ -149,98 +188,126 @@ meshtastic_ChannelSettings MeshBeaconModule::beaconChannelSettings(const meshtas return ch; } -bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p) +MeshBeaconModule::RadioConfigResult MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p) { - // True while a beacon radio switch is in effect and still needs undoing. We track the switch - // explicitly rather than inferring it from "live config differs from the snapshot", because that - // heuristic both missed cases (a channel name/PSK swap that left preset/slot/region unchanged would - // never be restored) and fired falsely (a legitimate non-beacon channel edit would be reverted on - // the next TX). With the flag the restore fires for ANY field we changed and only when we changed - // it - including on TX-failure paths, which route through this same restore call. - static bool radioSwitched = false; - - meshtastic_ChannelSettings *primaryCh = &channels.getByIndex(channels.getPrimaryIndex()).settings; meshtastic_Config_LoRaConfig_ModemPreset targetPreset; uint16_t targetSlot; - - const auto channelDiffers = [&](const meshtastic_ChannelSettings &target) { - return strncmp(primaryCh->name, target.name, sizeof(primaryCh->name)) != 0 || primaryCh->psk.size != target.psk.size || - memcmp(primaryCh->psk.bytes, target.psk.bytes, primaryCh->psk.size) != 0 || - primaryCh->channel_num != target.channel_num; - }; - bool legacyHopOverride = false; meshtastic_Config_LoRaConfig_RegionCode sidecarRegion = meshtastic_Config_LoRaConfig_RegionCode_UNSET; bool sidecarHasChannel = false; meshtastic_ChannelSettings sidecarChannel = {}; - if (p && getTargetRadioSettings(p, &targetPreset, &targetSlot, &legacyHopOverride, &sidecarRegion, &sidecarHasChannel, - &sidecarChannel)) { + const bool hasTarget = p && getTargetRadioSettings(p, &targetPreset, &targetSlot, &legacyHopOverride, &sidecarRegion, + &sidecarHasChannel, &sidecarChannel); + + if (hasTarget) { + { + concurrency::LockGuard guard(&radioConfigLock); + if (radioConfigTransitionInProgress) + return RadioConfigResult::IN_PROGRESS; + + meshtastic_ChannelSettings *primaryCh = &channels.getByIndex(channels.getPrimaryIndex()).settings; + const auto channelDiffers = [&](const meshtastic_ChannelSettings &target) { + return strncmp(primaryCh->name, target.name, sizeof(primaryCh->name)) != 0 || + primaryCh->psk.size != target.psk.size || + memcmp(primaryCh->psk.bytes, target.psk.bytes, primaryCh->psk.size) != 0 || + primaryCh->channel_num != target.channel_num; + }; + + // Legacy compatibility: older firmware (pre-v2.7.20) drops hop_start==0 packets via the + // pre-hop check before decryption, so they can't see has_bitfield to validate them. + if (legacyHopOverride) + p->hop_start = 1; - // Legacy compatibility: older firmware (pre-v2.7.20) drops hop_start==0 packets via the - // pre-hop check before decryption, so they can't see has_bitfield to validate them. - // Setting hop_start=1 (with hop_limit remaining 0) makes the packet pass the old check - // while still being zero-hop (hop_limit=0 prevents any rebroadcast). - if (legacyHopOverride) - p->hop_start = 1; + const meshtastic_Config_LoRaConfig_RegionCode targetRegion = + (sidecarRegion != meshtastic_Config_LoRaConfig_RegionCode_UNSET) ? sidecarRegion : config.lora.region; + const meshtastic_ChannelSettings *overrideCh = sidecarHasChannel ? &sidecarChannel : nullptr; + meshtastic_ChannelSettings targetChannel = beaconChannelSettings(*primaryCh, targetPreset, overrideCh); - const meshtastic_Config_LoRaConfig_RegionCode targetRegion = - (sidecarRegion != meshtastic_Config_LoRaConfig_RegionCode_UNSET) ? sidecarRegion : config.lora.region; - const meshtastic_ChannelSettings *overrideCh = sidecarHasChannel ? &sidecarChannel : nullptr; + if (targetPreset == config.lora.modem_preset && targetSlot == config.lora.channel_num && + targetRegion == config.lora.region && !channelDiffers(targetChannel)) + return RadioConfigResult::UNCHANGED; - meshtastic_ChannelSettings targetChannel = beaconChannelSettings(*primaryCh, targetPreset, overrideCh); + if (beaconTxConfigInvalid(p, iface)) { + LOG_DEBUG("Beacon: target preset %d/region %d invalid (or ham mismatch), not switching", targetPreset, + targetRegion); + return RadioConfigResult::UNCHANGED; + } - if (targetPreset == config.lora.modem_preset && targetSlot == config.lora.channel_num && - targetRegion == config.lora.region && !channelDiffers(targetChannel)) - return false; + if (!radioConfigTemporary.load(std::memory_order_acquire)) { + originalModemPreset = config.lora.modem_preset; + originalLoraChannel = config.lora.channel_num; + originalRegion = config.lora.region; + originalPrimaryChannel = *primaryCh; + } - // Guard: never key up on an invalid target config - bad preset for the region, or an - // unlicensed node keying up on a ham-only region. Refuse the switch here so we never - // transmit on it; the radio driver drops the packet outright (see RadioLibInterface, - // beaconTxConfigInvalid) rather than letting it fall through onto the current config. - if (beaconTxConfigInvalid(p)) { - LOG_DEBUG("Beacon: target preset %d/region %d invalid (or ham mismatch), not switching", targetPreset, targetRegion); - return false; + LOG_INFO("Beacon: switch radio for packet 0x%08x to preset=%d slot=%u region=%d", p->id, targetPreset, targetSlot, + targetRegion); + config.lora.modem_preset = targetPreset; + config.lora.channel_num = targetSlot; + config.lora.region = targetRegion; + *primaryCh = targetChannel; + channels.fixupChannel(channels.getPrimaryIndex()); + p->channel = channels.getHash(channels.getPrimaryIndex()); + radioConfigTemporary.store(true, std::memory_order_release); + radioRestorePending.store(false, std::memory_order_release); + radioConfigTransitionInProgress = true; } - // Snapshot current (non-beacon) settings so we restore to the latest config. Skip while a - // switch is already active, so a second switch before the restore can't capture the beacon - // config as the "home" we later restore to. - if (!radioSwitched) { - originalModemPreset = config.lora.modem_preset; - originalLoraChannel = config.lora.channel_num; - originalRegion = config.lora.region; - originalPrimaryChannel = *primaryCh; + if (iface->reconfigureTransient()) { + concurrency::LockGuard guard(&radioConfigLock); + radioConfigTransitionInProgress = false; + return RadioConfigResult::RECONFIGURED; } - LOG_INFO("Beacon: switch radio for packet 0x%08x to preset=%d slot=%u region=%d", p->id, targetPreset, targetSlot, - targetRegion); - config.lora.modem_preset = targetPreset; - config.lora.channel_num = targetSlot; - if (targetRegion != config.lora.region) - config.lora.region = targetRegion; - *primaryCh = targetChannel; - - channels.fixupChannel(channels.getPrimaryIndex()); - p->channel = channels.getHash(channels.getPrimaryIndex()); - iface->reconfigure(); - radioSwitched = true; - return true; + LOG_ERROR("Beacon: radio switch failed for packet 0x%08x; restoring home config", p->id); + { + concurrency::LockGuard guard(&radioConfigLock); + meshtastic_ChannelSettings *primaryCh = &channels.getByIndex(channels.getPrimaryIndex()).settings; + config.lora.modem_preset = originalModemPreset; + config.lora.channel_num = originalLoraChannel; + config.lora.region = originalRegion; + *primaryCh = originalPrimaryChannel; + primaryCh->name[sizeof(primaryCh->name) - 1] = '\0'; + channels.fixupChannel(channels.getPrimaryIndex()); + radioRestorePending.store(true, std::memory_order_release); + } + const bool restored = iface->reconfigureCommitted(); + { + concurrency::LockGuard guard(&radioConfigLock); + radioConfigTemporary.store(!restored, std::memory_order_release); + radioRestorePending.store(!restored, std::memory_order_release); + radioConfigTransitionInProgress = false; + } + return RadioConfigResult::FAILED; + } - } else if ((!p || !getTargetRadioSettings(p, nullptr, nullptr)) && radioSwitched) { + { + concurrency::LockGuard guard(&radioConfigLock); + if (radioConfigTransitionInProgress) + return RadioConfigResult::IN_PROGRESS; + if (!radioConfigTemporary.load(std::memory_order_acquire)) + return RadioConfigResult::UNCHANGED; LOG_INFO("Beacon: restoring radio config after beacon TX"); + meshtastic_ChannelSettings *primaryCh = &channels.getByIndex(channels.getPrimaryIndex()).settings; config.lora.modem_preset = originalModemPreset; config.lora.channel_num = originalLoraChannel; config.lora.region = originalRegion; *primaryCh = originalPrimaryChannel; primaryCh->name[sizeof(primaryCh->name) - 1] = '\0'; - channels.fixupChannel(channels.getPrimaryIndex()); - iface->reconfigure(); - radioSwitched = false; - return true; + radioRestorePending.store(true, std::memory_order_release); + radioConfigTransitionInProgress = true; } - return false; + + const bool restored = iface->reconfigureCommitted(); + { + concurrency::LockGuard guard(&radioConfigLock); + radioConfigTemporary.store(!restored, std::memory_order_release); + radioRestorePending.store(!restored, std::memory_order_release); + radioConfigTransitionInProgress = false; + } + return restored ? RadioConfigResult::RECONFIGURED : RadioConfigResult::FAILED; } // --------------------------------------------------------------------------- @@ -283,10 +350,12 @@ void MeshBeaconBroadcastModule::rebuildCache() void MeshBeaconBroadcastModule::sendBeaconPacket(meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset targetPreset, bool has_channel, const meshtastic_ChannelSettings *overrideChannel) { + const PacketId packetId = p->id; const bool cryptoOverride = has_channel && overrideChannel && (overrideChannel->name[0] != '\0' || overrideChannel->psk.size > 0); if (!cryptoOverride) { - router->send(p); + if (router->send(p) != ERRNO_OK) + clearTargetRadioSettings(packetId); return; } @@ -300,10 +369,12 @@ void MeshBeaconBroadcastModule::sendBeaconPacket(meshtastic_MeshPacket *p, mesht primary.settings = beaconChannelSettings(saved, targetPreset, overrideChannel); channels.fixupChannel(channels.getPrimaryIndex()); - router->send(p); // encrypts with the beacon channel's key and stamps its hash + const ErrorCode sendResult = router->send(p); // encrypts with the beacon channel's key and stamps its hash primary.settings = saved; channels.fixupChannel(channels.getPrimaryIndex()); + if (sendResult != ERRNO_OK) + clearTargetRadioSettings(packetId); } void MeshBeaconBroadcastModule::sendBeacon() @@ -479,8 +550,12 @@ void MeshBeaconBroadcastModule::sendBeacon() const meshtastic_ChannelSettings *chPtr = tgt.has_channel ? &tgt.channel : nullptr; const auto applyTarget = [&](meshtastic_MeshPacket *p) { - if (presetDiffers || legacySplit) - setTargetRadioSettings(p, tgt.preset, tgt.slot, legacySplit, tgt.region, tgt.has_channel, chPtr); + if ((presetDiffers || legacySplit) && + !setTargetRadioSettings(p, tgt.preset, tgt.slot, legacySplit, tgt.region, tgt.has_channel, chPtr)) { + LOG_WARN("Beacon: no target-radio metadata slot for packet 0x%08x; dropping", p->id); + packetPool.release(p); + return; + } sendBeaconPacket(p, tgt.preset, tgt.has_channel, chPtr); }; diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h index e9faeea4cf8..587586355e1 100644 --- a/src/modules/MeshBeaconModule.h +++ b/src/modules/MeshBeaconModule.h @@ -34,20 +34,32 @@ typedef struct { class MeshBeaconModule { public: + enum class RadioConfigResult : uint8_t { UNCHANGED, RECONFIGURED, IN_PROGRESS, FAILED }; + MeshBeaconModule(); /** * Reconfigure the radio for beacon TX, or restore to original config if p is NULL. - * Returns true if the radio was reconfigured (caller must re-run transmit delay for CCA). + * Returns RECONFIGURED when the caller must re-run transmit delay for CCA, or FAILED when + * the switch/restore did not complete and transmission must not proceed. * Driven by broadcast_on_preset / broadcast_on_channel from MeshBeaconConfig. */ - static bool reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p); + static RadioConfigResult reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p); + + /// True while the radio is using beacon settings and the home configuration still needs restoration. + static bool radioConfigIsTemporary(); + + /// True after a home-profile restore failed and normal RX/TX must remain paused. + static bool radioRestoreIsPending(); + + /// Copy the canonical home configuration, even while a beacon temporarily owns the radio. + static bool getHomeRadioConfig(meshtastic_Config_LoRaConfig &lora, meshtastic_ChannelSettings *primary = nullptr); /** * Associate target radio settings with an outgoing packet by its ID. - * Sidecar holds 8 entries; evicts slot 0 on overflow. + * Returns false if no lifetime slot is available; callers must not transmit the packet in that case. */ - static void + static bool setTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset preset, uint16_t slot, bool legacyHopOverride = false, meshtastic_Config_LoRaConfig_RegionCode region = meshtastic_Config_LoRaConfig_RegionCode_UNSET, @@ -64,6 +76,7 @@ class MeshBeaconModule * Called from RadioLibInterface::completeSending(). */ static void clearTargetRadioSettings(const meshtastic_MeshPacket *p); + static void clearTargetRadioSettings(PacketId id); /** * True if p is tagged for a beacon radio switch whose target config must NOT be transmitted: @@ -71,7 +84,7 @@ class MeshBeaconModule * (licensed-only) region. The radio driver drops such packets rather than sending them on the * current config. False for any packet without a sidecar entry (normal traffic is never affected). */ - static bool beaconTxConfigInvalid(const meshtastic_MeshPacket *p); + static bool beaconTxConfigInvalid(const meshtastic_MeshPacket *p, RadioInterface *iface = nullptr); protected: /** diff --git a/src/platform/portduino/SimRadio.cpp b/src/platform/portduino/SimRadio.cpp index 2786903ebbb..cba9b28d112 100644 --- a/src/platform/portduino/SimRadio.cpp +++ b/src/platform/portduino/SimRadio.cpp @@ -1,6 +1,9 @@ #include "SimRadio.h" #include "MeshService.h" #include "Router.h" +#if !MESHTASTIC_EXCLUDE_BEACON +#include "modules/MeshBeaconModule.h" +#endif SimRadio::SimRadio() : NotifiedWorkerThread("SimRadio") { @@ -11,16 +14,33 @@ SimRadio *SimRadio::instance; ErrorCode SimRadio::send(meshtastic_MeshPacket *p) { + if (configApplyTxInhibited()) { + LOG_WARN("Drop simulated Tx packet because radio configuration recovery failed"); + packetPool.release(p); + return ERRNO_DISABLED; + } + printPacket("enqueuing for send", p); bool dropped = false; - ErrorCode res = txQueue.enqueue(p, &dropped) ? ERRNO_OK : ERRNO_UNKNOWN; + meshtastic_MeshPacket *evicted = nullptr; + ErrorCode res = txQueue.enqueue(p, &dropped, &evicted) ? ERRNO_OK : ERRNO_UNKNOWN; + + if (evicted) { +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::clearTargetRadioSettings(evicted); +#endif + packetPool.release(evicted); + } if (dropped) { txDrop++; } if (res != ERRNO_OK) { // we weren't able to queue it, so we must drop it to prevent leaks +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::clearTargetRadioSettings(p); +#endif packetPool.release(p); return res; } @@ -32,9 +52,33 @@ ErrorCode SimRadio::send(meshtastic_MeshPacket *p) return res; } +bool SimRadio::requestConfigApply(RadioConfigApplyRequest *request) +{ + if (!RadioInterface::requestConfigApply(request)) + return false; + + notify(CONFIG_APPLY_PENDING, false); + return true; +} + +bool SimRadio::finalizeConfigApply(RadioConfigApplyRequest *request) +{ + if (configApplyTxStartActive() || sendingPacket != nullptr) { + notify(CONFIG_APPLY_PENDING, false); + return false; + } + if (!RadioInterface::finalizeConfigApply(request)) + return false; + if (!txQueue.empty()) + setTransmitDelay(); + return true; +} + void SimRadio::setTransmitDelay() { meshtastic_MeshPacket *p = txQueue.getFront(); + if (!p) + return; // We want all sending/receiving to be done by our daemon thread. // We use a delay here because this packet might have been sent in response to a packet we just received. // So we want to make sure the other side has had a chance to reconfigure its radio. @@ -98,6 +142,9 @@ void SimRadio::completeSending() printPacket("Completed sending", p); // We are done sending that packet, release it +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::clearTargetRadioSettings(p); +#endif packetPool.release(p); // LOG_DEBUG("Done with send"); } @@ -136,8 +183,12 @@ bool SimRadio::isChannelActive() bool SimRadio::cancelSending(NodeNum from, PacketId id) { auto p = txQueue.remove(from, id); - if (p) + if (p) { +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::clearTargetRadioSettings(p); +#endif packetPool.release(p); // free the packet we just removed + } bool result = (p != NULL); LOG_DEBUG("cancelSending id=0x%08x, removed=%d", id, result); @@ -164,6 +215,8 @@ void SimRadio::onNotify(uint32_t notification) startTransmitTimer(); break; case TRANSMIT_DELAY_COMPLETED: + if (configApplyBarrierIsSet()) + break; if (receivingPacket) { // This happens when we had a timer pending and we started receiving handleReceiveInterrupt(); startTransmitTimer(); @@ -183,23 +236,38 @@ void SimRadio::onNotify(uint32_t notification) setTransmitDelay(); // reset random delay } else { // Send any outgoing packets we have ready - meshtastic_MeshPacket *txp = txQueue.dequeue(); - assert(txp); - startSend(txp); - // Packet has been sent, count it toward our TX airtime utilization. - uint32_t xmitMsec = RadioInterface::getPacketTime(txp); - airTime->logAirtime(TX_LOG, xmitMsec); - - notifyLater(xmitMsec, ISR_TX, false); // Model the time it is busy sending + if (claimConfigApplyTxStart()) { + meshtastic_MeshPacket *txp = txQueue.dequeue(); + assert(txp); + if (configApplyTxInhibited()) { + LOG_WARN("Drop queued simulated Tx packet because radio configuration recovery failed"); + packetPool.release(txp); + setTransmitDelay(); + } else { + startSend(txp); + // Packet has been sent, count it toward our TX airtime utilization. + uint32_t xmitMsec = RadioInterface::getPacketTime(txp); + airTime->logAirtime(TX_LOG, xmitMsec); + + notifyLater(xmitMsec, ISR_TX, false); // Model the time it is busy sending + } + releaseConfigApplyTxStart(); + } } } } else { // LOG_DEBUG("done with txqueue"); } break; + case CONFIG_APPLY_PENDING: + break; default: assert(0); // We expected to receive a valid notification from the ISR } + + serviceConfigApply(millis()); + if (configApplyPending()) + notifyLater(25, CONFIG_APPLY_PENDING, false); } /** start an immediate transmit */ diff --git a/src/platform/portduino/SimRadio.h b/src/platform/portduino/SimRadio.h index 43b99e92e84..7cf9387061e 100644 --- a/src/platform/portduino/SimRadio.h +++ b/src/platform/portduino/SimRadio.h @@ -9,7 +9,7 @@ class SimRadio : public RadioInterface, protected concurrency::NotifiedWorkerThread { - enum PendingISR { ISR_NONE = 0, ISR_RX, ISR_TX, TRANSMIT_DELAY_COMPLETED }; + enum PendingISR { ISR_NONE = 0, ISR_RX, ISR_TX, TRANSMIT_DELAY_COMPLETED, CONFIG_APPLY_PENDING }; MeshPacketQueue txQueue = MeshPacketQueue(MAX_TX_QUEUE); @@ -21,6 +21,8 @@ class SimRadio : public RadioInterface, protected concurrency::NotifiedWorkerThr static SimRadio *instance; virtual ErrorCode send(meshtastic_MeshPacket *p) override; + bool requestConfigApply(RadioConfigApplyRequest *request) override; + bool finalizeConfigApply(RadioConfigApplyRequest *request) override; /** can we detect a LoRa preamble on the current channel? */ virtual bool isChannelActive(); @@ -36,6 +38,17 @@ class SimRadio : public RadioInterface, protected concurrency::NotifiedWorkerThr /** Attempt to find a packet in the TxQueue. Returns true if the packet was found. */ virtual bool findInTxQueue(NodeNum from, PacketId id) override; +#ifdef PIO_UNIT_TESTING + bool completeNextSendingForTest() + { + if (sendingPacket || txQueue.empty()) + return false; + sendingPacket = txQueue.dequeue(); + completeSending(); + return true; + } +#endif + /** * Start waiting to receive a message * diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index 15fe9e782e8..e7c6d070afe 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -28,6 +28,21 @@ class AdminModuleTestShim : public AdminModule editTransactionActivityMs = millis(); } int savedSegments() const { return lastSaveWhatForTest; } + int persistedSegments() const { return persistedSaveWhatForTest; } + uint32_t persistenceCount() const { return persistenceCountForTest; } + const meshtastic_Config_LoRaConfig &persistedLoRa() const { return persistedLoRaForTest; } + void saveUnrelatedConfig(int segments) { saveChanges(segments, false); } + bool loRaConfigPending() const { return loRaConfigApplyPending.load(std::memory_order_acquire); } + void holdLoRaSaveForTest() + { + concurrency::LockGuard guard(&loRaSaveLock); + ++loRaSavesInProgress; + } + void releaseLoRaSaveForTest() + { + concurrency::LockGuard guard(&loRaSaveLock); + --loRaSavesInProgress; + } bool editTransactionOpen() const { return hasOpenEditTransaction; } // Backdate past the idle window so a test sees an abandoned transaction without waiting it out. diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 4b88be70147..c303f4df20f 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -12,15 +12,25 @@ */ #include "Channels.h" +#include "Default.h" #include "DisplayFormatters.h" #include "FSCommon.h" +#include "GPS.h" #include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" +#include "Power.h" +#include "PowerMon.h" #include "RadioInterface.h" +#include "Router.h" #include "TestUtil.h" +#include "main.h" #include "mesh/Channels.h" #include "modules/AdminModule.h" +#include "platform/portduino/PortduinoGlue.h" +#if HAS_LORA_FEM +#include "mesh/LoRaFEMInterface.h" +#endif #include "modules/NodeInfoModule.h" #include #include @@ -48,10 +58,147 @@ class MockMeshService : public MeshService capturedWarnings.push_back(n->message); releaseClientNotificationToPool(n); } + + void reloadOwner(bool shouldSave = true) override + { + sawLicensedOwner = owner.is_licensed; + licensedChannelsWereSanitized = + channels.getByIndex(0).settings.psk.size == 0 && channels.getByIndex(1).role == meshtastic_Channel_Role_DISABLED && + channels.getByIndex(1).settings.psk.size == 0 && channels.getByIndex(2).settings.psk.size == 0; + MeshService::reloadOwner(shouldSave); + } + + bool sawLicensedOwner = false; + bool licensedChannelsWereSanitized = false; +}; + +class ConfigChangedCounter : public Observer +{ + public: + uint32_t count = 0; + + protected: + int onNotify(void *) override + { + count++; + return 0; + } }; static MockMeshService *mockMeshService; +class ScriptedConfigApplyRadio : public RadioInterface +{ + public: + bool wideLora() override { return true; } + + bool supportsLoRaBandwidth(float bandwidthKHz, bool wideBand) override + { + return !limitWideBandwidth || !wideBand || bandwidthKHz == 203.125f || bandwidthKHz == 406.25f || bandwidthKHz == 812.5f; + } + + void emulateLr1121Bandwidths() { limitWideBandwidth = true; } + + bool requestConfigApply(RadioConfigApplyRequest *request) override + { + if (rejectNextRequest) { + rejectNextRequest = false; + request->result.store(RadioConfigApplyResult::BUSY); + return false; + } + if (pendingRequest) { + request->result.store(RadioConfigApplyResult::BUSY); + return false; + } + pendingRequest = request; + pendingRequest->acceptedRadioId = configApplyOwnerId(); + pendingRequest->result.store(RadioConfigApplyResult::PENDING, std::memory_order_release); + requestCount++; + return true; + } + + void complete(RadioConfigApplyResult result) + { + TEST_ASSERT_NOT_NULL(pendingRequest); + if (result == RadioConfigApplyResult::ROLLBACK_FAILED) + setConfigApplyTxInhibit(true); + pendingRequest->result.store(result); + } + + bool finalizeConfigApply(RadioConfigApplyRequest *request) override + { + if (!ownsConfigApplyRequest(*request)) + return false; + if (deferNextFinalization) { + deferNextFinalization = false; + return false; + } + TEST_ASSERT_EQUAL_PTR(pendingRequest, request); + finalizedRegion = config.lora.region; + pendingRequest = nullptr; + finalizeCount++; + return true; + } + + const RadioConfigApplyRequest *pending() const { return pendingRequest; } + + uint32_t requests() const { return requestCount; } + uint32_t finalizations() const { return finalizeCount; } + meshtastic_Config_LoRaConfig_RegionCode regionAtFinalize() const { return finalizedRegion; } + void rejectNext() { rejectNextRequest = true; } + void deferNextFinalize() { deferNextFinalization = true; } + + void reset() + { + pendingRequest = nullptr; + requestCount = 0; + finalizeCount = 0; + finalizedRegion = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + rejectNextRequest = false; + deferNextFinalization = false; + limitWideBandwidth = false; + setConfigApplyTxInhibit(false); + } + + uint32_t getPacketTime(uint32_t, bool) override { return 0; } + ErrorCode send(meshtastic_MeshPacket *) override { return ERRNO_OK; } + + private: + RadioConfigApplyRequest *pendingRequest = nullptr; + uint32_t requestCount = 0; + uint32_t finalizeCount = 0; + meshtastic_Config_LoRaConfig_RegionCode finalizedRegion = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + bool rejectNextRequest = false; + bool deferNextFinalization = false; + bool limitWideBandwidth = false; +}; + +class ChannelConfigApplyRadio : public RadioInterface +{ + public: + void beginSendingForTest() { sendingPacket = &inFlightPacket; } + void completeSendingForTest() { sendingPacket = nullptr; } + uint32_t reconfigurations() const { return reconfigureCount; } + + bool reconfigure() override + { + reconfigureCount++; + return true; + } + + uint32_t getPacketTime(uint32_t, bool) override { return 0; } + ErrorCode send(meshtastic_MeshPacket *) override { return ERRNO_OK; } + + private: + meshtastic_MeshPacket inFlightPacket = meshtastic_MeshPacket_init_zero; + uint32_t reconfigureCount = 0; +}; + +static Router *savedRouter; +static Router *testRouter; +static ScriptedConfigApplyRadio *scriptedRadio; +static AdminModule *savedAdminModule; + // ----------------------------------------------------------------------- // getRegion() tests // ----------------------------------------------------------------------- @@ -961,6 +1108,7 @@ static NodeInfoModule *savedNodeInfoModule; static meshtastic_DeviceState savedDeviceState; static meshtastic_User savedOwner; static meshtastic_LocalConfig savedConfig; +static meshtastic_LocalModuleConfig savedModuleConfig; static meshtastic_ChannelFile savedChannelFile; static void replaceAdminRadioGlobals() @@ -970,6 +1118,7 @@ static void replaceAdminRadioGlobals() savedDeviceState = devicestate; savedOwner = owner; savedConfig = config; + savedModuleConfig = moduleConfig; savedChannelFile = channelFile; replacementNodeDB = new NodeDB(); nodeDB = replacementNodeDB; @@ -987,6 +1136,7 @@ static void restoreAdminRadioGlobals() devicestate = savedDeviceState; owner = savedOwner; config = savedConfig; + moduleConfig = savedModuleConfig; channelFile = savedChannelFile; initRegion(); adminRadioGlobalsActive = false; @@ -1031,12 +1181,19 @@ static void test_handleSetOwner_persistsLicensedChannelSanitation() meshtastic_User licensed = meshtastic_User_init_zero; licensed.is_licensed = true; - testAdmin->deferSaves(); nodeInfoModule = reinterpret_cast(1); // reloadOwner(false) only checks presence - testAdmin->handleSetOwner(licensed); + TEST_ASSERT_TRUE(testAdmin->handleSetOwner(licensed)); + + TEST_ASSERT_FALSE(owner.is_licensed); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); TEST_ASSERT_TRUE(testAdmin->savedSegments() & SEGMENT_CHANNELS); + TEST_ASSERT_TRUE(owner.is_licensed); assertLicensedChannelsSanitized(); + TEST_ASSERT_TRUE(mockMeshService->sawLicensedOwner); + TEST_ASSERT_TRUE(mockMeshService->licensedChannelsWereSanitized); uint8_t encoded[meshtastic_ChannelFile_size]; const size_t encodedSize = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ChannelFile_msg, &channelFile); @@ -1048,6 +1205,31 @@ static void test_handleSetOwner_persistsLicensedChannelSanitation() TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "sanitized reload must not trigger another persistence write"); } +static void test_handleSetOwner_radioFailureKeepsLicenseAndChannelsUnchanged() +{ + replaceAdminRadioGlobals(); + owner = meshtastic_User_init_zero; + 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.tx_enabled = true; + initRegion(); + installEncryptedAndAdminChannels(); + const meshtastic_ChannelFile previousChannels = channelFile; + + meshtastic_User licensed = owner; + licensed.is_licensed = true; + TEST_ASSERT_TRUE(testAdmin->handleSetOwner(licensed)); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + + scriptedRadio->complete(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK); + mockMeshService->loop(); + + TEST_ASSERT_FALSE(owner.is_licensed); + TEST_ASSERT_EQUAL_MEMORY(&previousChannels, &channelFile, sizeof(channelFile)); +} + static void test_bootDefense_sanitizesStaleLicensedChannelsOnce() { owner = meshtastic_User_init_zero; @@ -1098,6 +1280,14 @@ static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCo return c; } +static void applyLoRaConfig(const meshtastic_Config &c, bool fromOthers) +{ + TEST_ASSERT_TRUE(testAdmin->handleSetConfig(c, fromOthers)); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); +} + static void test_handleSetConfig_persistsLicensedFirstRegionIdentity() { replaceAdminRadioGlobals(); @@ -1108,10 +1298,9 @@ static void test_handleSetConfig_persistsLicensedFirstRegionIdentity() config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; initRegion(); - testAdmin->deferSaves(); const meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); - testAdmin->handleSetConfig(c, false); + applyLoRaConfig(c, false); const int expectedSegments = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; TEST_ASSERT_EQUAL_INT(expectedSegments, testAdmin->savedSegments()); @@ -1132,7 +1321,8 @@ static void test_handleSetConfig_fromOthers_invalidPresetRejected() meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO); - testAdmin->handleSetConfig(c, true); // fromOthers = true + TEST_ASSERT_FALSE(testAdmin->handleSetConfig(c, true)); + TEST_ASSERT_NULL(scriptedRadio->pending()); // fromOthers=true: invalid preset should be rejected, old preset preserved TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); @@ -1151,7 +1341,7 @@ static void test_handleSetConfig_fromLocal_invalidPresetClamped() meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO); - testAdmin->handleSetConfig(c, false); // fromOthers = false (local client) + applyLoRaConfig(c, false); // fromOthers=false: invalid preset should be clamped to the region's default const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); @@ -1171,7 +1361,7 @@ static void test_handleSetConfig_fromOthers_validPresetAccepted() meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); - testAdmin->handleSetConfig(c, true); // fromOthers = true + applyLoRaConfig(c, true); // Valid preset should be accepted regardless of fromOthers TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset); @@ -1192,7 +1382,8 @@ static void test_handleSetConfig_fromOthers_invalidChannelNumFullyRejected() 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_FALSE(testAdmin->handleSetConfig(c, true)); + TEST_ASSERT_NULL(scriptedRadio->pending()); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); @@ -1226,7 +1417,7 @@ static void test_handleSetConfig_fromLocal_customBandwidthZeroClampedToDefault() c.payload_variant.lora.coding_rate = 5; c.payload_variant.lora.bandwidth = 0; // the footgun: unset custom bandwidth - testAdmin->handleSetConfig(c, false); // fromOthers = false (local client) + applyLoRaConfig(c, false); TEST_ASSERT_FALSE(config.lora.use_preset); TEST_ASSERT_NOT_EQUAL_UINT16(0, config.lora.bandwidth); // must not persist as 0 @@ -1250,12 +1441,82 @@ static void test_handleSetConfig_fromOthers_customBandwidthZeroClampedToDefault( c.payload_variant.lora.coding_rate = 5; c.payload_variant.lora.bandwidth = 0; - testAdmin->handleSetConfig(c, true); // fromOthers = true + applyLoRaConfig(c, true); TEST_ASSERT_FALSE(config.lora.use_preset); TEST_ASSERT_EQUAL_UINT16(bwKHzToCode(LORA_BW_DEFAULT_KHZ), config.lora.bandwidth); } +static meshtastic_Config makeLora24CustomBandwidth(uint16_t bandwidth) +{ + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_LORA_24, false, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + c.payload_variant.lora.bandwidth = bandwidth; + c.payload_variant.lora.spread_factor = 7; + c.payload_variant.lora.coding_rate = 5; + return c; +} + +static void test_handleSetConfig_localUnsupportedLr1121BandwidthUsesRegionDefault() +{ + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + scriptedRadio->emulateLr1121Bandwidths(); + + applyLoRaConfig(makeLora24CustomBandwidth(125), false); + + TEST_ASSERT_FALSE(config.lora.use_preset); + TEST_ASSERT_EQUAL_UINT16(800, config.lora.bandwidth); +} + +static void test_handleSetConfig_remoteUnsupportedLr1121BandwidthIsRejected() +{ + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + scriptedRadio->emulateLr1121Bandwidths(); + + TEST_ASSERT_FALSE(testAdmin->handleSetConfig(makeLora24CustomBandwidth(125), true)); + TEST_ASSERT_NULL(scriptedRadio->pending()); + TEST_ASSERT_TRUE(config.lora.use_preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); +} + +static void test_handleSetConfig_crossRegionLocalUnsupportedLr1121BandwidthUsesRegionDefault() +{ + 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(); + scriptedRadio->emulateLr1121Bandwidths(); + + applyLoRaConfig(makeLora24CustomBandwidth(125), false); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_LORA_24, config.lora.region); + TEST_ASSERT_FALSE(config.lora.use_preset); + TEST_ASSERT_EQUAL_UINT16(800, config.lora.bandwidth); +} + +static void test_handleSetConfig_crossRegionRemoteUnsupportedLr1121BandwidthIsRejected() +{ + 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(); + scriptedRadio->emulateLr1121Bandwidths(); + + TEST_ASSERT_FALSE(testAdmin->handleSetConfig(makeLora24CustomBandwidth(125), true)); + TEST_ASSERT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); +} + // In preset mode bandwidth 0 is the norm (the preset supplies it); the ingest clamp must leave it // untouched so preset configs still read back bandwidth 0. static void test_handleSetConfig_fromLocal_presetBandwidthZeroLeftUntouched() @@ -1270,7 +1531,7 @@ static void test_handleSetConfig_fromLocal_presetBandwidthZeroLeftUntouched() makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); c.payload_variant.lora.bandwidth = 0; - testAdmin->handleSetConfig(c, false); + applyLoRaConfig(c, false); TEST_ASSERT_TRUE(config.lora.use_preset); TEST_ASSERT_EQUAL_UINT16(0, config.lora.bandwidth); @@ -1291,7 +1552,7 @@ static void test_handleSetConfig_fromLocal_customBandwidthNonZeroPreserved() c.payload_variant.lora.coding_rate = 5; c.payload_variant.lora.bandwidth = 125; - testAdmin->handleSetConfig(c, false); + applyLoRaConfig(c, false); TEST_ASSERT_FALSE(config.lora.use_preset); TEST_ASSERT_EQUAL_UINT16(125, config.lora.bandwidth); @@ -1476,7 +1737,12 @@ static void test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion() 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_TRUE(testAdmin->handleSetConfig(c, true)); + TEST_ASSERT_EQUAL_UINT32(1, scriptedRadio->requests()); + TEST_ASSERT_TRUE(testAdmin->loRaConfigPending()); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); 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); @@ -1498,7 +1764,8 @@ static void test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejecte meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST); - testAdmin->handleSetConfig(c, true); // fromOthers = true + TEST_ASSERT_FALSE(testAdmin->handleSetConfig(c, true)); + TEST_ASSERT_NULL(scriptedRadio->pending()); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); @@ -1550,6 +1817,23 @@ static void sendAdmin(meshtastic_AdminMessage &m) testAdmin->handleReceivedProtobuf(mp, &m); } +static void sendAdminWithResponse(meshtastic_AdminMessage &m) +{ + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = 0; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.want_response = true; + testAdmin->handleReceivedProtobuf(mp, &m); +} + +static void sendSetOwner(const meshtastic_User &candidate) +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_set_owner_tag; + m.set_owner = candidate; + sendAdmin(m); +} + static void sendSetChannel(const meshtastic_Channel &ch) { meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; @@ -1558,6 +1842,52 @@ static void sendSetChannel(const meshtastic_Channel &ch) sendAdmin(m); } +static void sendSetLora(const meshtastic_Config_LoRaConfig &lora) +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_set_config_tag; + m.set_config.which_payload_variant = meshtastic_Config_lora_tag; + m.set_config.payload_variant.lora = lora; + sendAdmin(m); +} + +static meshtastic_Channel readChannel(uint8_t index) +{ + meshtastic_AdminMessage request = meshtastic_AdminMessage_init_zero; + request.which_payload_variant = meshtastic_AdminMessage_get_channel_request_tag; + request.get_channel_request = index + 1; + sendAdminWithResponse(request); + + const meshtastic_MeshPacket *reply = testAdmin->reply(); + TEST_ASSERT_NOT_NULL(reply); + meshtastic_AdminMessage response = meshtastic_AdminMessage_init_zero; + TEST_ASSERT_TRUE( + pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &response)); + TEST_ASSERT_EQUAL(meshtastic_AdminMessage_get_channel_response_tag, response.which_payload_variant); + const meshtastic_Channel channel = response.get_channel_response; + testAdmin->drainReply(); + return channel; +} + +static void completeQueuedChannelApply() +{ + if (!scriptedRadio->pending()) + return; + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); +} + +static bool replyHasRoutingError(meshtastic_Routing_Error expected) +{ + const meshtastic_MeshPacket *reply = testAdmin->reply(); + if (!reply || reply->decoded.portnum != meshtastic_PortNum_ROUTING_APP) + return false; + meshtastic_Routing routing = meshtastic_Routing_init_zero; + if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_Routing_msg, &routing)) + return false; + return routing.which_variant == meshtastic_Routing_error_reason_tag && routing.error_reason == expected; +} + static void sendBeginEdit() { meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; @@ -1595,130 +1925,1760 @@ static void usePresetLongFast() owner.is_licensed = false; } -static void test_warn_singleChannel_variantName_oneSpecificMessage() +static void configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode region) { - usePresetLongFast(); - // Name is a case/space variant of the preset with the default key: a single name issue. - sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); - TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); - TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); + config.has_lora = true; + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = region; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + config.lora.tx_enabled = region != meshtastic_Config_LoRaConfig_RegionCode_UNSET; + config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED; + owner.is_licensed = false; + initRegion(); + channelFile = meshtastic_ChannelFile_init_zero; + channels.initDefaults(); + channels.onConfigChanged(); + strncpy(moduleConfig.mqtt.root, default_mqtt_root, sizeof(moduleConfig.mqtt.root)); + moduleConfig.mqtt.root[sizeof(moduleConfig.mqtt.root) - 1] = '\0'; } -static void test_warn_singleChannel_nameAndPsk_collapsedToCatchAll() +static meshtastic_Config_LoRaConfig makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode region, + meshtastic_Config_LoRaConfig_ModemPreset preset) { - usePresetLongFast(); - // Variant name AND a non-default key: two issues on one channel collapse to one catch-all. - sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", CUSTOM_KEY, 2)); - TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); - TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name and PSK issues on channel 0")); + meshtastic_Config_LoRaConfig candidate = meshtastic_Config_LoRaConfig_init_zero; + candidate.region = region; + candidate.use_preset = true; + candidate.modem_preset = preset; + candidate.tx_enabled = true; + return candidate; } -static void test_warn_cleanChannel_noMessage() +static void test_editTransaction_loraCandidate_staysInactiveUntilCommit() { - usePresetLongFast(); - // Exact preset name + default key: nothing to warn about. - sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "LongFast", DEFAULT_KEY, 1)); - TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); -} + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const auto previous = config.lora; + const auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); -static void test_warn_transaction_multipleChannels_singleCoalescedMessage() -{ - usePresetLongFast(); sendBeginEdit(); - sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); - sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1)); - // Nothing emitted yet - warnings are deferred until commit. - TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + sendSetLora(candidate); - sendCommitEdit(); - // Exactly one message, naming both channels. - TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); - TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1")); + TEST_ASSERT_TRUE(testAdmin->editTransactionOpen()); + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(0, scriptedRadio->requests()); + TEST_ASSERT_EQUAL_MEMORY(&previous, &config.lora, sizeof(previous)); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); } -static void test_warn_transaction_singleChannel_keepsSpecificMessage() +static void test_editTransaction_commit_queuesOneRadioApply() { - usePresetLongFast(); + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const auto previous = config.lora; + const auto first = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + const auto committed = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW); + sendBeginEdit(); - sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); - TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + sendSetLora(first); + sendSetLora(committed); + TEST_ASSERT_EQUAL_UINT32(0, scriptedRadio->requests()); sendCommitEdit(); - // One flagged channel: the specific message verbatim, not the plural catch-all. - TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); - TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); - TEST_ASSERT_EQUAL_INT(0, warningsContaining("on channels")); + + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + TEST_ASSERT_TRUE(testAdmin->loRaConfigPending()); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(1, scriptedRadio->requests()); + TEST_ASSERT_EQUAL(committed.modem_preset, scriptedRadio->pending()->candidate.modem_preset); + TEST_ASSERT_EQUAL_MEMORY(&previous, &config.lora, sizeof(previous)); } -// An idle transaction is retired by the next admin message, flushing the warnings it held. -static void test_editTransaction_abandoned_isRetiredOnNextAdminMessage() +static void test_editTransaction_expiryQueuesAndPersistsStagedLora() { - usePresetLongFast(); - sendBeginEdit(); - sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); - // Deferred, exactly as before: nothing emitted while the transaction looks alive. - TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); - TEST_ASSERT_TRUE(testAdmin->editTransactionOpen()); + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const auto previous = config.lora; + const auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + sendBeginEdit(); + testAdmin->saveUnrelatedConfig(SEGMENT_CHANNELS); + sendSetLora(candidate); testAdmin->ageEditTransaction(); - sendGetDeviceMetadata(); // any later admin message, from any client + sendGetDeviceMetadata(); TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); - TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); + TEST_ASSERT_TRUE(testAdmin->loRaConfigPending()); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(1, scriptedRadio->requests()); + TEST_ASSERT_EQUAL_MEMORY(&previous, &config.lora, sizeof(previous)); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); + + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL(candidate.region, config.lora.region); + TEST_ASSERT_TRUE(testAdmin->persistedSegments() & SEGMENT_CONFIG); + TEST_ASSERT_TRUE(testAdmin->persistedSegments() & SEGMENT_CHANNELS); + TEST_ASSERT_EQUAL(candidate.region, testAdmin->persistedLoRa().region); } -// A write arriving after abandonment is saved, not deferred to a commit that never comes. -static void test_editTransaction_abandoned_laterWriteIsNoLongerDeferred() +static void test_editTransaction_loraFailure_savesOnlyUnrelatedSegments() { - usePresetLongFast(); + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const auto previous = config.lora; + const auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + constexpr int unrelatedSegments = SEGMENT_CONFIG | SEGMENT_CHANNELS; + sendBeginEdit(); - testAdmin->ageEditTransaction(); + sendSetLora(candidate); + testAdmin->saveUnrelatedConfig(unrelatedSegments); + sendCommitEdit(); - sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); + TEST_ASSERT_EQUAL_UINT32(1, scriptedRadio->requests()); - // The write itself retired the stale transaction, so its own warning is emitted immediately. - TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); - TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); + scriptedRadio->complete(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK); + mockMeshService->loop(); + + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_EQUAL_UINT32(1, testAdmin->persistenceCount()); + TEST_ASSERT_EQUAL_INT(unrelatedSegments, testAdmin->persistedSegments()); + TEST_ASSERT_EQUAL_MEMORY(&previous, &config.lora, sizeof(previous)); + TEST_ASSERT_EQUAL(previous.region, testAdmin->persistedLoRa().region); } -// A transaction still in use is left alone: each write refreshes the window. -static void test_editTransaction_active_isNotRetired() +struct LicensedMenuSnapshot { + meshtastic_User owner; + meshtastic_Config_DeviceConfig device; + meshtastic_Config_LoRaConfig lora; + meshtastic_Config_SecurityConfig security; + meshtastic_ChannelFile channels; + meshtastic_LocalModuleConfig moduleConfig; + bool migrationPending; +}; + +static void configureLicensedMenuBaseline(bool licensed) { - usePresetLongFast(); - sendBeginEdit(); - sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); - sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1)); + if (!adminRadioGlobalsActive) + replaceAdminRadioGlobals(); + nodeInfoModule = reinterpret_cast(1); + configureLoRaTransactionBaseline(licensed ? meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M + : meshtastic_Config_LoRaConfig_RegionCode_US); - TEST_ASSERT_TRUE(testAdmin->editTransactionOpen()); - TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + owner = meshtastic_User_init_zero; + strncpy(owner.long_name, licensed ? "N0CALL" : "Before", sizeof(owner.long_name) - 1); + strncpy(owner.short_name, licensed ? "N0CL" : "BFR", sizeof(owner.short_name) - 1); + owner.is_licensed = licensed; - sendCommitEdit(); - TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); - TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1")); + config.device = meshtastic_Config_DeviceConfig_init_zero; + config.device.node_info_broadcast_secs = 1234; + config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL; + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora.override_duty_cycle = licensed; + config.lora.override_frequency = licensed ? 145.5f : 144.5f; + config.lora.tx_power = licensed ? 127 : 30; + config.lora.tx_enabled = !licensed; + initRegion(); + + installEncryptedAndAdminChannels(); + if (licensed) + channels.ensureLicensedOperation(); + + if (licensed) { + config.security.private_key.size = 32; + config.security.public_key.size = 32; + owner.public_key.size = 32; + memset(config.security.private_key.bytes, 0x11, 32); + memset(config.security.public_key.bytes, 0x22, 32); + memset(owner.public_key.bytes, 0x22, 32); + } + nodeDB->licensedIdentityMigrationPending = true; } -static void test_warn_license_noTransaction_emittedImmediately() +static meshtastic_Config_LoRaConfig makeMenuRegionCandidate(meshtastic_Config_LoRaConfig_RegionCode region) { - usePresetLongFast(); - owner.is_licensed = true; - // Setting a channel that still carries a key triggers ensureLicensedOperation() to strip it. - sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2)); - TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated")); + auto candidate = config.lora; + candidate.region = region; + candidate.channel_num = 0; + candidate.use_preset = true; + candidate.modem_preset = getRegion(region)->getDefaultPreset(); + return candidate; } -static void test_warn_license_transaction_coalescedToSingleMessage() +static LicensedMenuSnapshot captureLicensedMenuSnapshot() { - usePresetLongFast(); - owner.is_licensed = true; - sendBeginEdit(); - // Two separate triggers within one transaction (two channels with keys to strip). - sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2)); - sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "", CUSTOM_KEY, 2)); - TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + return { + owner, config.device, config.lora, config.security, channelFile, moduleConfig, nodeDB->licensedIdentityMigrationPending}; +} - sendCommitEdit(); - // Collapsed to a single licensed-mode notice (and no channel warning, since names are blank). - TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated")); +static void assertLicensedMenuSnapshot(const LicensedMenuSnapshot &expected) +{ + TEST_ASSERT_EQUAL_MEMORY(&expected.owner, &owner, sizeof(owner)); + TEST_ASSERT_EQUAL_MEMORY(&expected.device, &config.device, sizeof(config.device)); + TEST_ASSERT_EQUAL_MEMORY(&expected.lora, &config.lora, sizeof(config.lora)); + TEST_ASSERT_EQUAL_MEMORY(&expected.security, &config.security, sizeof(config.security)); + TEST_ASSERT_EQUAL_MEMORY(&expected.channels, &channelFile, sizeof(channelFile)); + TEST_ASSERT_EQUAL_MEMORY(&expected.moduleConfig, &moduleConfig, sizeof(moduleConfig)); + TEST_ASSERT_EQUAL(expected.migrationPending, nodeDB->licensedIdentityMigrationPending); +} + +static void test_menuLoRaHelper_ordinaryQueueSuccessBusyAndNoSyncPersistence() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const auto previous = config.lora; + const auto first = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + const auto second = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW); + ConfigChangedCounter configChanges; + configChanges.observe(&mockMeshService->configChanged); + + TEST_ASSERT_TRUE(testAdmin->requestMenuLoRaConfig(first)); + TEST_ASSERT_FALSE(testAdmin->requestMenuLoRaConfig(second)); + + TEST_ASSERT_EQUAL_UINT32(1, scriptedRadio->requests()); + TEST_ASSERT_TRUE(testAdmin->loRaConfigPending()); + TEST_ASSERT_EQUAL_MEMORY(&previous, &config.lora, sizeof(previous)); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); + TEST_ASSERT_EQUAL_INT(0, testAdmin->savedSegments()); + TEST_ASSERT_EQUAL_UINT32(0, configChanges.count); +} + +static void test_menuLoRaHelper_queueRejectionDiscardsLicensedTransitions() +{ + const AdminModule::MenuLoRaTransition transitions[] = {AdminModule::MenuLoRaTransition::ENTER_LICENSED, + AdminModule::MenuLoRaTransition::EXIT_LICENSED}; + + for (const auto transition : transitions) { + configureLicensedMenuBaseline(transition == AdminModule::MenuLoRaTransition::EXIT_LICENSED); + const auto target = transition == AdminModule::MenuLoRaTransition::ENTER_LICENSED + ? meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M + : meshtastic_Config_LoRaConfig_RegionCode_US; + const auto candidate = makeMenuRegionCandidate(target); + const auto before = captureLicensedMenuSnapshot(); + scriptedRadio->reset(); + scriptedRadio->rejectNext(); + capturedWarnings.clear(); + + TEST_ASSERT_FALSE(testAdmin->requestMenuLoRaConfig(candidate, transition)); + + assertLicensedMenuSnapshot(before); + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(0, scriptedRadio->requests()); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); + TEST_ASSERT_EQUAL_INT(0, testAdmin->savedSegments()); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + } +} + +static void test_menuLoRaHelper_busyDiscardsLicensedTransitions() +{ + const AdminModule::MenuLoRaTransition transitions[] = {AdminModule::MenuLoRaTransition::ENTER_LICENSED, + AdminModule::MenuLoRaTransition::EXIT_LICENSED}; + + for (const auto transition : transitions) { + configureLicensedMenuBaseline(transition == AdminModule::MenuLoRaTransition::EXIT_LICENSED); + const auto ordinary = makeLoRaCandidate(config.lora.region, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + TEST_ASSERT_TRUE(testAdmin->requestMenuLoRaConfig(ordinary)); + const auto before = captureLicensedMenuSnapshot(); + const auto target = transition == AdminModule::MenuLoRaTransition::ENTER_LICENSED + ? meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M + : meshtastic_Config_LoRaConfig_RegionCode_US; + + TEST_ASSERT_FALSE(testAdmin->requestMenuLoRaConfig(makeMenuRegionCandidate(target), transition)); + + assertLicensedMenuSnapshot(before); + TEST_ASSERT_EQUAL_UINT32(1, scriptedRadio->requests()); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); + TEST_ASSERT_EQUAL_INT(0, testAdmin->savedSegments()); + + scriptedRadio->complete(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK); + mockMeshService->loop(); + scriptedRadio->reset(); + capturedWarnings.clear(); + } +} + +static void assertMenuTransitionTerminalFailures(AdminModule::MenuLoRaTransition transition) +{ + const RadioConfigApplyResult failures[] = {RadioConfigApplyResult::TIMED_OUT, + RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK, + RadioConfigApplyResult::ROLLBACK_FAILED}; + + for (const auto result : failures) { + configureLicensedMenuBaseline(transition == AdminModule::MenuLoRaTransition::EXIT_LICENSED); + const auto target = transition == AdminModule::MenuLoRaTransition::ENTER_LICENSED + ? meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M + : meshtastic_Config_LoRaConfig_RegionCode_US; + const auto before = captureLicensedMenuSnapshot(); + scriptedRadio->reset(); + capturedWarnings.clear(); + + TEST_ASSERT_TRUE(testAdmin->requestMenuLoRaConfig(makeMenuRegionCandidate(target), transition)); + assertLicensedMenuSnapshot(before); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); + + scriptedRadio->complete(result); + mockMeshService->loop(); + + assertLicensedMenuSnapshot(before); + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); + TEST_ASSERT_EQUAL_INT(0, testAdmin->savedSegments()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Radio configuration apply failed")); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + } +} + +static void test_menuLoRaHelper_enterLicensedTerminalFailuresLeaveSnapshotUnchanged() +{ + assertMenuTransitionTerminalFailures(AdminModule::MenuLoRaTransition::ENTER_LICENSED); +} + +static void test_menuLoRaHelper_exitLicensedTerminalFailuresLeaveSnapshotUnchanged() +{ + assertMenuTransitionTerminalFailures(AdminModule::MenuLoRaTransition::EXIT_LICENSED); +} + +static void test_menuLoRaHelper_enterLicensedAppliesOnceAfterRadioSuccess() +{ + configureLicensedMenuBaseline(false); + const auto candidate = makeMenuRegionCandidate(meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M); + const auto before = captureLicensedMenuSnapshot(); + + TEST_ASSERT_TRUE(testAdmin->requestMenuLoRaConfig(candidate, AdminModule::MenuLoRaTransition::ENTER_LICENSED)); + + assertLicensedMenuSnapshot(before); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_TRUE(scriptedRadio->pending()->candidate.override_duty_cycle); + TEST_ASSERT_FALSE(scriptedRadio->pending()->previousLicensed); + TEST_ASSERT_TRUE(scriptedRadio->pending()->candidateLicensed); + TEST_ASSERT_EQUAL_INT8(30, scriptedRadio->pending()->candidate.tx_power); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 144.5f, scriptedRadio->pending()->candidate.override_frequency); + TEST_ASSERT_FALSE(scriptedRadio->pending()->candidate.tx_enabled); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); + + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + + TEST_ASSERT_TRUE(owner.is_licensed); + TEST_ASSERT_EQUAL_STRING("N0CALL", owner.long_name); + TEST_ASSERT_EQUAL_STRING("N0CL", owner.short_name); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M, config.lora.region); + TEST_ASSERT_TRUE(config.lora.override_duty_cycle); + TEST_ASSERT_EQUAL_INT8(30, config.lora.tx_power); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 144.5f, config.lora.override_frequency); + TEST_ASSERT_FALSE(config.lora.tx_enabled); + TEST_ASSERT_EQUAL_UINT32(600, config.device.node_info_broadcast_secs); + TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY, config.device.rebroadcast_mode); + assertLicensedChannelsSanitized(); +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + TEST_ASSERT_EQUAL_UINT(32, config.security.private_key.size); + TEST_ASSERT_EQUAL_UINT(32, config.security.public_key.size); + TEST_ASSERT_EQUAL_UINT(32, owner.public_key.size); + TEST_ASSERT_FALSE(nodeDB->licensedIdentityMigrationPending); +#endif + TEST_ASSERT_EQUAL_UINT32(1, testAdmin->persistenceCount()); + TEST_ASSERT_EQUAL_INT(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE | SEGMENT_CHANNELS, + testAdmin->persistedSegments()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated")); +} + +static void test_menuLoRaHelper_exitLicensedAppliesNormalizedStateOnceAfterRadioSuccess() +{ + configureLicensedMenuBaseline(true); + const auto securityBefore = config.security; + const auto channelsBefore = channelFile; + const auto candidate = makeMenuRegionCandidate(meshtastic_Config_LoRaConfig_RegionCode_US); + + TEST_ASSERT_TRUE(testAdmin->requestMenuLoRaConfig(candidate, AdminModule::MenuLoRaTransition::EXIT_LICENSED)); + + TEST_ASSERT_TRUE(owner.is_licensed); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_FALSE(scriptedRadio->pending()->candidate.override_duty_cycle); + TEST_ASSERT_TRUE(scriptedRadio->pending()->previousLicensed); + TEST_ASSERT_FALSE(scriptedRadio->pending()->candidateLicensed); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.0f, scriptedRadio->pending()->candidate.override_frequency); + TEST_ASSERT_EQUAL_INT8(getRegion(meshtastic_Config_LoRaConfig_RegionCode_US)->powerLimit, + scriptedRadio->pending()->candidate.tx_power); + TEST_ASSERT_TRUE(scriptedRadio->pending()->candidate.tx_enabled); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); + + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + + TEST_ASSERT_FALSE(owner.is_licensed); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + TEST_ASSERT_FALSE(config.lora.override_duty_cycle); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.0f, config.lora.override_frequency); + TEST_ASSERT_EQUAL_INT8(getRegion(meshtastic_Config_LoRaConfig_RegionCode_US)->powerLimit, config.lora.tx_power); + TEST_ASSERT_TRUE(config.lora.tx_enabled); + TEST_ASSERT_EQUAL_MEMORY(&securityBefore, &config.security, sizeof(config.security)); + TEST_ASSERT_EQUAL_MEMORY(&channelsBefore, &channelFile, sizeof(channelFile)); + TEST_ASSERT_EQUAL_UINT32(1, testAdmin->persistenceCount()); + TEST_ASSERT_EQUAL_INT(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE, + testAdmin->persistedSegments()); +} + +static void test_setHamMode_directProtobufPathCommitsAfterRadioSuccess() +{ + configureLicensedMenuBaseline(false); + meshtastic_HamParameters params = meshtastic_HamParameters_init_zero; + strncpy(params.call_sign, "K1ABC", sizeof(params.call_sign) - 1); + strncpy(params.short_name, "K1A", sizeof(params.short_name) - 1); + params.tx_power = 20; + params.frequency = 145.5f; + + testAdmin->handleSetHamMode(params); + + TEST_ASSERT_FALSE(owner.is_licensed); + TEST_ASSERT_TRUE(testAdmin->loRaConfigPending()); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_FALSE(scriptedRadio->pending()->previousLicensed); + TEST_ASSERT_TRUE(scriptedRadio->pending()->candidateLicensed); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); + + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + + TEST_ASSERT_TRUE(owner.is_licensed); + TEST_ASSERT_EQUAL_STRING("K1ABC", owner.long_name); + TEST_ASSERT_EQUAL_STRING("K1A", owner.short_name); + TEST_ASSERT_TRUE(config.lora.override_duty_cycle); + TEST_ASSERT_EQUAL_INT8(20, config.lora.tx_power); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 145.5f, config.lora.override_frequency); + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(1, testAdmin->persistenceCount()); +} + +static void test_setHamMode_directProtobufFailureLeavesStateUnchanged() +{ + configureLicensedMenuBaseline(false); + const auto before = captureLicensedMenuSnapshot(); + meshtastic_HamParameters params = meshtastic_HamParameters_init_zero; + strncpy(params.call_sign, "K1ABC", sizeof(params.call_sign) - 1); + strncpy(params.short_name, "K1A", sizeof(params.short_name) - 1); + params.tx_power = 20; + params.frequency = 145.5f; + + testAdmin->handleSetHamMode(params); + scriptedRadio->complete(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK); + mockMeshService->loop(); + + assertLicensedMenuSnapshot(before); + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); +} + +static void test_setLoraConfig_invalidRegionRejectedBeforeTransaction() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + testAdmin->deferSaves(); + RadioInterface::uses_default_frequency_slot = false; + RadioInterface::uses_custom_channel_name = false; + error_code = meshtastic_CriticalErrorCode_NONE; + const auto previous = config.lora; + const uint32_t requestCount = scriptedRadio->requests(); + auto candidate = + makeLoRaCandidate((meshtastic_Config_LoRaConfig_RegionCode)254, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + sendSetLora(candidate); + + TEST_ASSERT_TRUE(replyHasRoutingError(meshtastic_Routing_Error_BAD_REQUEST)); + TEST_ASSERT_EQUAL_UINT32(requestCount, scriptedRadio->requests()); + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_MEMORY(&previous, &config.lora, sizeof(previous)); + TEST_ASSERT_FALSE(RadioInterface::uses_default_frequency_slot); + TEST_ASSERT_FALSE(RadioInterface::uses_custom_channel_name); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING, error_code); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_NOT_EQUAL(std::string::npos, capturedWarnings[0].find("Region code 254 is not recognized")); + TEST_ASSERT_EQUAL_INT(0, testAdmin->savedSegments()); + + mockMeshService->loop(); + + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(0, warningsContaining("Radio configuration apply failed")); + error_code = meshtastic_CriticalErrorCode_NONE; +} + +static void test_setLoraConfig_remoteInvalidRejectedBeforeTransaction() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + testAdmin->deferSaves(); + RadioInterface::uses_default_frequency_slot = false; + RadioInterface::uses_custom_channel_name = false; + error_code = meshtastic_CriticalErrorCode_NONE; + const auto previous = config.lora; + const uint32_t requestCount = scriptedRadio->requests(); + auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + candidate.channel_num = 5000; + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_lora_tag; + c.payload_variant.lora = candidate; + + TEST_ASSERT_FALSE(testAdmin->handleSetConfig(c, true)); + TEST_ASSERT_EQUAL_UINT32(requestCount, scriptedRadio->requests()); + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_MEMORY(&previous, &config.lora, sizeof(previous)); + TEST_ASSERT_FALSE(RadioInterface::uses_default_frequency_slot); + TEST_ASSERT_FALSE(RadioInterface::uses_custom_channel_name); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING, error_code); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_NOT_EQUAL(std::string::npos, capturedWarnings[0].find("Channel number 5000 invalid")); + TEST_ASSERT_EQUAL_INT(0, testAdmin->savedSegments()); + + mockMeshService->loop(); + + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(0, warningsContaining("Radio configuration apply failed")); + error_code = meshtastic_CriticalErrorCode_NONE; +} + +static void test_setLoraConfig_doesNotMutateOrSaveBeforeHardwareSuccess() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + + sendSetLora(candidate); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); + TEST_ASSERT_EQUAL_INT(0, testAdmin->savedSegments()); + TEST_ASSERT_TRUE(testAdmin->loRaConfigPending()); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, scriptedRadio->pending()->candidate.modem_preset); +} + +static void test_setLoraConfig_normalizationSideEffectsWaitForHardwareSuccess() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + RadioInterface::uses_default_frequency_slot = false; + RadioInterface::uses_custom_channel_name = false; + error_code = meshtastic_CriticalErrorCode_NONE; + auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + candidate.channel_num = UINT32_MAX; + + sendSetLora(candidate); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); + TEST_ASSERT_FALSE(RadioInterface::uses_default_frequency_slot); + TEST_ASSERT_FALSE(RadioInterface::uses_custom_channel_name); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_NONE, error_code); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(0, testAdmin->savedSegments()); + + scriptedRadio->complete(RadioConfigApplyResult::TIMED_OUT); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); + TEST_ASSERT_FALSE(RadioInterface::uses_default_frequency_slot); + TEST_ASSERT_FALSE(RadioInterface::uses_custom_channel_name); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_NONE, error_code); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + + capturedWarnings.clear(); + error_code = meshtastic_CriticalErrorCode_NONE; + sendSetLora(candidate); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + + TEST_ASSERT_TRUE(RadioInterface::uses_default_frequency_slot); + TEST_ASSERT_FALSE(RadioInterface::uses_custom_channel_name); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING, error_code); + TEST_ASSERT_EQUAL_INT(2, (int)capturedWarnings.size()); + error_code = meshtastic_CriticalErrorCode_NONE; +} + +static void test_setLoraConfig_success_appliesSideEffectsThenPersists() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_NOT_NULL(gps.get()); + gps->disable(); + + meshtastic_Channel primary = makeChannel(0, meshtastic_Channel_Role_PRIMARY, "LongFast", DEFAULT_KEY, sizeof(DEFAULT_KEY)); + channels.setChannel(primary); + + auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + candidate.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED; +#if HAS_LORA_FEM + loraFEMInterface.setLnaCanControl(false); +#endif + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_lora_tag; + c.payload_variant.lora = candidate; + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, myRegion->code); + TEST_ASSERT_EQUAL_STRING(default_mqtt_root, moduleConfig.mqtt.root); + TEST_ASSERT_FALSE(gps->isEnabled()); + TEST_ASSERT_EQUAL_INT(0, testAdmin->savedSegments()); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_INT8(getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868)->powerLimit, + scriptedRadio->pending()->candidate.tx_power); +#if HAS_LORA_FEM + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT, scriptedRadio->pending()->candidate.fem_lna_mode); +#else + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED, scriptedRadio->pending()->candidate.fem_lna_mode); +#endif + + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL_UINT32(1, scriptedRadio->finalizations()); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, scriptedRadio->regionAtFinalize()); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, config.lora.region); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, myRegion->code); + TEST_ASSERT_EQUAL_INT8(getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868)->powerLimit, config.lora.tx_power); + TEST_ASSERT_EQUAL_STRING("msh/EU_868", moduleConfig.mqtt.root); + TEST_ASSERT_TRUE(gps->isEnabled()); +#if HAS_LORA_FEM + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT, config.lora.fem_lna_mode); +#else + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED, config.lora.fem_lna_mode); +#endif + TEST_ASSERT_EQUAL_INT(SEGMENT_CONFIG | SEGMENT_MODULECONFIG, testAdmin->savedSegments()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("matches the old preset")); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); +} + +static void test_setLoraConfig_failure_keepsOldConfigAndWarns() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + RadioInterface::uses_default_frequency_slot = false; + RadioInterface::uses_custom_channel_name = false; + error_code = meshtastic_CriticalErrorCode_NONE; + const auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_lora_tag; + c.payload_variant.lora = candidate; + testAdmin->handleSetConfig(c, false); + scriptedRadio->complete(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL_UINT32(1, scriptedRadio->finalizations()); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, scriptedRadio->regionAtFinalize()); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, myRegion->code); + TEST_ASSERT_EQUAL_INT(0, testAdmin->savedSegments()); + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_FALSE(RadioInterface::uses_default_frequency_slot); + TEST_ASSERT_FALSE(RadioInterface::uses_custom_channel_name); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_NONE, error_code); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Radio configuration apply failed")); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + + capturedWarnings.clear(); + testAdmin->handleSetConfig(c, false); + scriptedRadio->complete(RadioConfigApplyResult::TIMED_OUT); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + TEST_ASSERT_EQUAL_INT(0, testAdmin->savedSegments()); + TEST_ASSERT_FALSE(RadioInterface::uses_default_frequency_slot); + TEST_ASSERT_FALSE(RadioInterface::uses_custom_channel_name); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_NONE, error_code); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Radio configuration apply failed")); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_UINT32(2, scriptedRadio->requests()); +} + +static void test_setLoraConfig_interfaceReplacementKeepsOldConfig() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + sendSetLora(candidate); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + + auto replacement = std::make_unique(); + scriptedRadio = replacement.get(); + testRouter->addInterface(std::move(replacement)); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + TEST_ASSERT_EQUAL_UINT32(0, scriptedRadio->finalizations()); + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("radio interface changed")); +} + +static void test_setLoraConfig_concurrentSavePersistsAfterTerminalResult() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + const auto unrelatedRole = meshtastic_Config_DeviceConfig_Role_CLIENT_BASE; + + sendSetLora(candidate); + config.device.role = unrelatedRole; + testAdmin->saveUnrelatedConfig(SEGMENT_CONFIG); + + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + TEST_ASSERT_EQUAL(unrelatedRole, config.device.role); + + scriptedRadio->complete(RadioConfigApplyResult::TIMED_OUT); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL_UINT32(1, testAdmin->persistenceCount()); + TEST_ASSERT_TRUE(testAdmin->persistedSegments() & SEGMENT_CONFIG); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, testAdmin->persistedLoRa().region); + TEST_ASSERT_EQUAL(unrelatedRole, config.device.role); +} + +static void test_editTransaction_busyCommitPreservesStagedLoraForRetry() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + sendBeginEdit(); + sendSetLora(candidate); + testAdmin->holdLoRaSaveForTest(); + sendCommitEdit(); + + TEST_ASSERT_TRUE(testAdmin->editTransactionOpen()); + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_EQUAL_UINT32(0, scriptedRadio->requests()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("remain staged for retry")); + + testAdmin->releaseLoRaSaveForTest(); + sendCommitEdit(); + + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + TEST_ASSERT_TRUE(testAdmin->loRaConfigPending()); + TEST_ASSERT_EQUAL_UINT32(1, scriptedRadio->requests()); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL(candidate.region, scriptedRadio->pending()->candidate.region); + TEST_ASSERT_EQUAL(candidate.modem_preset, scriptedRadio->pending()->candidate.modem_preset); + + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, config.lora.region); +} + +static void test_setLoraConfig_deferredSaveWaitsForRadioFinalization() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + sendSetLora(candidate); + scriptedRadio->deferNextFinalize(); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + + TEST_ASSERT_TRUE(testAdmin->loRaConfigPending()); + TEST_ASSERT_EQUAL_UINT32(0, scriptedRadio->finalizations()); + const uint32_t persistenceBeforeDeferredSave = testAdmin->persistenceCount(); + testAdmin->saveUnrelatedConfig(SEGMENT_MODULECONFIG); + TEST_ASSERT_EQUAL_UINT32(persistenceBeforeDeferredSave, testAdmin->persistenceCount()); + + mockMeshService->loop(); + + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_EQUAL_UINT32(1, scriptedRadio->finalizations()); + TEST_ASSERT_EQUAL_UINT32(persistenceBeforeDeferredSave + 1, testAdmin->persistenceCount()); + TEST_ASSERT_TRUE(testAdmin->persistedSegments() & SEGMENT_MODULECONFIG); +} + +static void test_setLoraConfig_rollbackFailure_keepsPersistedConfigAndWarnsRecoveryFailure() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + RadioInterface::uses_default_frequency_slot = false; + RadioInterface::uses_custom_channel_name = false; + error_code = meshtastic_CriticalErrorCode_NONE; + const auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_lora_tag; + c.payload_variant.lora = candidate; + testAdmin->handleSetConfig(c, false); + scriptedRadio->complete(RadioConfigApplyResult::ROLLBACK_FAILED); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, myRegion->code); + TEST_ASSERT_EQUAL_INT(0, testAdmin->savedSegments()); + TEST_ASSERT_TRUE(scriptedRadio->configApplyTxInhibited()); + TEST_ASSERT_FALSE(RadioInterface::uses_default_frequency_slot); + TEST_ASSERT_FALSE(RadioInterface::uses_custom_channel_name); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_NONE, error_code); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("radio recovery failed")); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); +} + +static void test_setLoraConfig_busy_returnsBadRequest() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const auto first = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + const auto second = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_433, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + sendSetLora(first); + sendSetLora(second); + + TEST_ASSERT_EQUAL_UINT32(1, scriptedRadio->requests()); + TEST_ASSERT_TRUE(replyHasRoutingError(meshtastic_Routing_Error_BAD_REQUEST)); +} + +static void test_destructiveAdminOperations_rejectedDuringPendingRadioApply() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + sendSetLora(candidate); + + const uint32_t disruptiveTags[] = { + meshtastic_AdminMessage_reboot_seconds_tag, meshtastic_AdminMessage_ota_request_tag, + meshtastic_AdminMessage_shutdown_seconds_tag, meshtastic_AdminMessage_factory_reset_config_tag, + meshtastic_AdminMessage_factory_reset_device_tag, meshtastic_AdminMessage_nodedb_reset_tag, + meshtastic_AdminMessage_enter_dfu_mode_request_tag, meshtastic_AdminMessage_restore_preferences_tag, + }; + const RadioConfigApplyRequest *pending = scriptedRadio->pending(); + + for (const auto tag : disruptiveTags) { + meshtastic_AdminMessage message = meshtastic_AdminMessage_init_zero; + message.which_payload_variant = tag; + sendAdmin(message); + + TEST_ASSERT_TRUE(replyHasRoutingError(meshtastic_Routing_Error_BAD_REQUEST)); + TEST_ASSERT_TRUE(testAdmin->loRaConfigPending()); + TEST_ASSERT_EQUAL_PTR(pending, scriptedRadio->pending()); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + testAdmin->drainReply(); + } +} + +static void test_powerCommands_dueBeforeRadioApply_waitForCompletion() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + rebootAtMsec = 1; + shutdownAtMsec = 1; + const auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + sendSetLora(candidate); + + Power powerUnderTest; + powerUnderTest.powerCommandsCheck(); + + TEST_ASSERT_EQUAL_UINT32(1, rebootAtMsec); + TEST_ASSERT_EQUAL_UINT32(1, shutdownAtMsec); + TEST_ASSERT_TRUE(testAdmin->loRaConfigPending()); + rebootAtMsec = 0; + shutdownAtMsec = 0; +} + +static void test_setLoraConfig_regionSideEffects_doNotRunBeforeSuccess() +{ + replaceAdminRadioGlobals(); + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_UNSET); + owner = meshtastic_User_init_zero; + owner.is_licensed = true; + config.security = meshtastic_Config_SecurityConfig_init_zero; + gps->disable(); + const auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_lora_tag; + c.payload_variant.lora = candidate; + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, myRegion->code); + TEST_ASSERT_EQUAL_STRING(default_mqtt_root, moduleConfig.mqtt.root); + TEST_ASSERT_EQUAL_UINT(0, config.security.private_key.size); + TEST_ASSERT_EQUAL_UINT(0, owner.public_key.size); + TEST_ASSERT_FALSE(gps->isEnabled()); + TEST_ASSERT_EQUAL_INT(0, testAdmin->savedSegments()); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + scriptedRadio->complete(RadioConfigApplyResult::TIMED_OUT); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + TEST_ASSERT_EQUAL_STRING(default_mqtt_root, moduleConfig.mqtt.root); + TEST_ASSERT_EQUAL_UINT(0, config.security.private_key.size); + TEST_ASSERT_EQUAL_UINT(0, owner.public_key.size); + TEST_ASSERT_FALSE(gps->isEnabled()); + TEST_ASSERT_EQUAL_INT(0, warningsContaining("Licensed signing requires")); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Radio configuration apply failed")); +} + +static void test_warn_singleChannel_variantName_oneSpecificMessage() +{ + usePresetLongFast(); + // Name is a case/space variant of the preset with the default key: a single name issue. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + completeQueuedChannelApply(); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); +} + +static void test_setPrimaryChannel_activeTxDefersRadioApplyUntilSendingCompletes() +{ + usePresetLongFast(); + const meshtastic_ChannelFile previous = channelFile; + auto radio = std::make_unique(); + auto *channelRadio = radio.get(); + scriptedRadio = nullptr; + testRouter->addInterface(std::move(radio)); + channelRadio->beginSendingForTest(); + + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "LongFast", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + TEST_ASSERT_EQUAL_MEMORY(&previous, &channelFile, sizeof(channelFile)); + channelRadio->serviceConfigApply(millis()); + + TEST_ASSERT_EQUAL_UINT32(0, channelRadio->reconfigurations()); + TEST_ASSERT_TRUE(testAdmin->loRaConfigPending()); + + channelRadio->completeSendingForTest(); + channelRadio->serviceConfigApply(millis()); + mockMeshService->loop(); + + const uint32_t reconfigurations = channelRadio->reconfigurations(); + const bool applyPending = testAdmin->loRaConfigPending(); + auto replacement = std::make_unique(); + scriptedRadio = replacement.get(); + testRouter->addInterface(std::move(replacement)); + + TEST_ASSERT_EQUAL_UINT32(1, reconfigurations); + TEST_ASSERT_FALSE(applyPending); + TEST_ASSERT_EQUAL_UINT32(sizeof(CUSTOM_KEY), channels.getPrimary().psk.size); +} + +static void test_setSecondaryChannel_doesNotReconfigureRadio() +{ + usePresetLongFast(); + ConfigChangedCounter configChanges; + configChanges.observe(&mockMeshService->configChanged); + const uint32_t requestsBefore = scriptedRadio->requests(); + + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "Private", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + + TEST_ASSERT_EQUAL_UINT32(requestsBefore, scriptedRadio->requests()); + TEST_ASSERT_EQUAL_UINT32(0, configChanges.count); +} + +static void test_setPrimaryChannel_applyFailureRestoresPreviousChannels() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const meshtastic_ChannelFile previous = channelFile; + + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Candidate", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + + TEST_ASSERT_EQUAL_MEMORY(&previous, &channelFile, sizeof(channelFile)); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + scriptedRadio->complete(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL_MEMORY(&previous, &channelFile, sizeof(channelFile)); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); +} + +static void test_setPrimaryChannel_queueFailureDoesNotLeakCandidateWarnings() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const meshtastic_ChannelFile previous = channelFile; + owner.is_licensed = true; + scriptedRadio->rejectNext(); + + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Candidate", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + + TEST_ASSERT_EQUAL_MEMORY(&previous, &channelFile, sizeof(channelFile)); + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + TEST_ASSERT_EQUAL_INT(0, warningsContaining("Licensed mode activated")); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("could not be queued")); +} + +static void test_setChannel_duringPendingApplyIsRejectedWithoutOrphaningFirstRequest() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "First", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + const RadioConfigApplyRequest *firstRequest = scriptedRadio->pending(); + TEST_ASSERT_NOT_NULL(firstRequest); + + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "Second", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + + TEST_ASSERT_TRUE(replyHasRoutingError(meshtastic_Routing_Error_BAD_REQUEST)); + TEST_ASSERT_EQUAL_PTR(firstRequest, scriptedRadio->pending()); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL_STRING("First", channels.getPrimary().name); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channelFile.channels[1].role); +} + +static void test_beginEdit_duringPendingApplyIsRejected() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "First", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + + sendBeginEdit(); + + TEST_ASSERT_TRUE(replyHasRoutingError(meshtastic_Routing_Error_BAD_REQUEST)); + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); +} + +static void test_editTransaction_channelThenLoraPreservesBothInApply() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Combined", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + sendSetLora( + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST)); + sendCommitEdit(); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_TRUE(scriptedRadio->pending()->hasPrimarySnapshots); + TEST_ASSERT_EQUAL_STRING("Combined", scriptedRadio->pending()->candidatePrimary.name); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL_STRING("Combined", channels.getPrimary().name); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset); + TEST_ASSERT_TRUE(testAdmin->persistedSegments() & SEGMENT_CHANNELS); + TEST_ASSERT_TRUE(testAdmin->persistedSegments() & SEGMENT_CONFIG); +} + +static void test_editTransaction_loraThenChannelPreservesBothInApply() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + sendBeginEdit(); + sendSetLora( + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST)); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Combined", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + sendCommitEdit(); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_TRUE(scriptedRadio->pending()->hasPrimarySnapshots); + TEST_ASSERT_EQUAL_STRING("Combined", scriptedRadio->pending()->candidatePrimary.name); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL_STRING("Combined", channels.getPrimary().name); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset); + TEST_ASSERT_TRUE(testAdmin->persistedSegments() & SEGMENT_CHANNELS); + TEST_ASSERT_TRUE(testAdmin->persistedSegments() & SEGMENT_CONFIG); +} + +static void test_editTransaction_getChannelReturnsStagedCandidate() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Staged", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + + TEST_ASSERT_EQUAL_STRING("", channels.getPrimary().name); + const meshtastic_Channel staged = readChannel(0); + TEST_ASSERT_EQUAL_STRING("Staged", staged.settings.name); +} + +static void test_editTransaction_defaultChannelHashUsesCandidatePreset() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const uint8_t defaultKey[] = {1}; + sendBeginEdit(); + sendSetLora( + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST)); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", defaultKey, sizeof(defaultKey))); + sendCommitEdit(); + + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset); + const int16_t publishedHash = channels.getHash(0); + channels.fixupChannel(0); + TEST_ASSERT_EQUAL(channels.getHash(0), publishedHash); +} + +static void test_editTransaction_abandonedLicenseExitQueuesRadioApply() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + owner.is_licensed = true; + config.lora.override_duty_cycle = true; + config.lora.tx_power = 30; + NodeInfoModule *previousNodeInfoModule = nodeInfoModule; + nodeInfoModule = reinterpret_cast(1); + sendBeginEdit(); + + meshtastic_User unlicensed = owner; + unlicensed.is_licensed = false; + sendSetOwner(unlicensed); + TEST_ASSERT_TRUE(owner.is_licensed); + + testAdmin->ageEditTransaction(); + sendGetDeviceMetadata(); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_TRUE(scriptedRadio->pending()->previousLicensed); + TEST_ASSERT_FALSE(scriptedRadio->pending()->candidateLicensed); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + TEST_ASSERT_FALSE(owner.is_licensed); + nodeInfoModule = previousNodeInfoModule; +} + +static void test_editTransaction_loraThenOwnerPreservesBothCandidates() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + NodeInfoModule *previousNodeInfoModule = nodeInfoModule; + nodeInfoModule = reinterpret_cast(1); + sendBeginEdit(); + sendSetLora( + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST)); + meshtastic_User licensed = owner; + licensed.is_licensed = true; + sendSetOwner(licensed); + sendCommitEdit(); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_TRUE(scriptedRadio->pending()->candidateLicensed); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, scriptedRadio->pending()->candidate.modem_preset); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + TEST_ASSERT_TRUE(owner.is_licensed); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset); + nodeInfoModule = previousNodeInfoModule; +} + +static void test_editTransaction_ownerThenLoraPreservesBothCandidates() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + NodeInfoModule *previousNodeInfoModule = nodeInfoModule; + nodeInfoModule = reinterpret_cast(1); + sendBeginEdit(); + meshtastic_User licensed = owner; + licensed.is_licensed = true; + sendSetOwner(licensed); + sendSetLora( + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST)); + sendCommitEdit(); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_TRUE(scriptedRadio->pending()->candidateLicensed); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, scriptedRadio->pending()->candidate.modem_preset); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + TEST_ASSERT_TRUE(owner.is_licensed); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset); + nodeInfoModule = previousNodeInfoModule; +} + +static uint32_t correctedChannelForPrimary(meshtastic_Config_LoRaConfig candidate, const char *primaryName) +{ + meshtastic_ChannelSettings primary = meshtastic_ChannelSettings_init_zero; + strncpy(primary.name, primaryName, sizeof(primary.name) - 1); + candidate.channel_num = UINT32_MAX; + const auto corrected = RadioInterface::normalizeConfigLora(candidate, true, &primary, scriptedRadio); + TEST_ASSERT_TRUE(corrected.valid); + return corrected.config.channel_num; +} + +static meshtastic_Config_LoRaConfig invalidSlotCandidate() +{ + auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + candidate.channel_num = UINT32_MAX; + return candidate; +} + +static void assertPendingSlotUsesPrimary(const char *primaryName) +{ + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + const auto &candidate = scriptedRadio->pending()->candidate; + TEST_ASSERT_EQUAL_UINT32(correctedChannelForPrimary(candidate, primaryName), candidate.channel_num); +} + +static void test_editTransaction_channelThenLoraCorrectsInvalidSlotAgainstFinalPrimary() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Final Primary", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + sendSetLora(invalidSlotCandidate()); + sendCommitEdit(); + + assertPendingSlotUsesPrimary("Final Primary"); + completeQueuedChannelApply(); + TEST_ASSERT_EQUAL_UINT32(correctedChannelForPrimary(config.lora, "Final Primary"), config.lora.channel_num); +} + +static void test_editTransaction_loraThenChannelCorrectsInvalidSlotAgainstFinalPrimary() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + sendBeginEdit(); + sendSetLora(invalidSlotCandidate()); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Final Primary", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + sendCommitEdit(); + + assertPendingSlotUsesPrimary("Final Primary"); + completeQueuedChannelApply(); + TEST_ASSERT_EQUAL_UINT32(correctedChannelForPrimary(config.lora, "Final Primary"), config.lora.channel_num); +} + +static void test_editTransaction_expiryCorrectsInvalidSlotAgainstFinalPrimary() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + sendBeginEdit(); + sendSetLora(invalidSlotCandidate()); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Expired Primary", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + testAdmin->ageEditTransaction(); + sendGetDeviceMetadata(); + + assertPendingSlotUsesPrimary("Expired Primary"); + completeQueuedChannelApply(); + TEST_ASSERT_EQUAL_UINT32(correctedChannelForPrimary(config.lora, "Expired Primary"), config.lora.channel_num); +} + +static void test_editTransaction_invalidSlotRollbackKeepsPublishedState() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + const auto publishedLoRa = config.lora; + const auto publishedChannels = channelFile; + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Rejected Primary", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + sendSetLora(invalidSlotCandidate()); + sendCommitEdit(); + + assertPendingSlotUsesPrimary("Rejected Primary"); + scriptedRadio->complete(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK); + mockMeshService->loop(); + TEST_ASSERT_EQUAL_MEMORY(&publishedLoRa, &config.lora, sizeof(config.lora)); + TEST_ASSERT_EQUAL_MEMORY(&publishedChannels, &channelFile, sizeof(channelFile)); +} + +static void test_editTransaction_ownerLastPreservesCorrectedSlotProvenance() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + NodeInfoModule *previousNodeInfoModule = nodeInfoModule; + nodeInfoModule = reinterpret_cast(1); + sendBeginEdit(); + sendSetLora(invalidSlotCandidate()); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Owner Final", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + meshtastic_User licensed = owner; + licensed.is_licensed = true; + sendSetOwner(licensed); + sendCommitEdit(); + + assertPendingSlotUsesPrimary("Owner Final"); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + TEST_ASSERT_EQUAL_UINT32(correctedChannelForPrimary(config.lora, "Owner Final"), config.lora.channel_num); + TEST_ASSERT_EQUAL_INT(2, warningsContaining("Channel number")); + nodeInfoModule = previousNodeInfoModule; +} + +static void test_editTransaction_compoundCorrectionTracksFinalPrimary() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + auto candidate = invalidSlotCandidate(); + candidate.modem_preset = static_cast(255); + + sendBeginEdit(); + sendSetLora(candidate); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Compound Final", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + NodeInfoModule *previousNodeInfoModule = nodeInfoModule; + nodeInfoModule = reinterpret_cast(1); + meshtastic_User licensed = owner; + licensed.is_licensed = true; + sendSetOwner(licensed); + sendCommitEdit(); + + assertPendingSlotUsesPrimary("Compound Final"); + completeQueuedChannelApply(); + TEST_ASSERT_EQUAL(getRegion(config.lora.region)->getDefaultPreset(), config.lora.modem_preset); + TEST_ASSERT_EQUAL_UINT32(correctedChannelForPrimary(config.lora, "Compound Final"), config.lora.channel_num); + TEST_ASSERT_EQUAL_INT(2, warningsContaining("Preset")); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Channel number")); + nodeInfoModule = previousNodeInfoModule; +} + +static void installNamedPrimary(const char *name) +{ + const auto primary = makeChannel(0, meshtastic_Channel_Role_PRIMARY, name, DEFAULT_KEY, sizeof(DEFAULT_KEY)); + Channels::setChannelInFile(channelFile, primary, 0); + channels.onConfigChanged(); +} + +static void test_channelRename_preservesConcreteDefaultSlotProvenance() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + installNamedPrimary("Old Primary"); + config.lora.channel_num = correctedChannelForPrimary(config.lora, "Old Primary"); + RadioInterface::uses_default_frequency_slot = true; + + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "New Primary", DEFAULT_KEY, sizeof(DEFAULT_KEY))); + sendCommitEdit(); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(0, scriptedRadio->pending()->candidate.channel_num); + completeQueuedChannelApply(); + TEST_ASSERT_EQUAL_UINT32(0, config.lora.channel_num); + TEST_ASSERT_TRUE(RadioInterface::uses_default_frequency_slot); +} + +static void test_channelRename_preservesZeroDefaultSlotProvenance() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + installNamedPrimary("Old Primary"); + config.lora.channel_num = 0; + RadioInterface::uses_default_frequency_slot = true; + + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "New Primary", DEFAULT_KEY, sizeof(DEFAULT_KEY))); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(0, scriptedRadio->pending()->candidate.channel_num); + completeQueuedChannelApply(); + TEST_ASSERT_TRUE(RadioInterface::uses_default_frequency_slot); +} + +static void test_channelRename_preservesExplicitSlot() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + installNamedPrimary("Old Primary"); + config.lora.channel_num = 2; + RadioInterface::uses_default_frequency_slot = false; + + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "New Primary", DEFAULT_KEY, sizeof(DEFAULT_KEY))); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(2, scriptedRadio->pending()->candidate.channel_num); + completeQueuedChannelApply(); + TEST_ASSERT_EQUAL_UINT32(2, config.lora.channel_num); + TEST_ASSERT_FALSE(RadioInterface::uses_default_frequency_slot); +} + +static void test_channelRename_rollbackPreservesDefaultSlotState() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + installNamedPrimary("Old Primary"); + const uint32_t oldSlot = correctedChannelForPrimary(config.lora, "Old Primary"); + config.lora.channel_num = oldSlot; + RadioInterface::uses_default_frequency_slot = true; + + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Rejected Primary", DEFAULT_KEY, sizeof(DEFAULT_KEY))); + scriptedRadio->complete(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL_UINT32(oldSlot, config.lora.channel_num); + TEST_ASSERT_TRUE(RadioInterface::uses_default_frequency_slot); + TEST_ASSERT_EQUAL_STRING("Old Primary", channels.getPrimary().name); +} + +static void configureConcreteDefaultSlot(const char *primaryName) +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + installNamedPrimary(primaryName); + config.lora.channel_num = correctedChannelForPrimary(config.lora, primaryName); + RadioInterface::uses_default_frequency_slot = true; +} + +static void test_loraPresetChange_preservesConcreteDefaultSlotProvenance() +{ + configureConcreteDefaultSlot("Preset Primary"); + auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + candidate.channel_num = config.lora.channel_num; + + sendSetLora(candidate); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(0, scriptedRadio->pending()->candidate.channel_num); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + TEST_ASSERT_EQUAL_UINT32(0, config.lora.channel_num); + TEST_ASSERT_TRUE(RadioInterface::uses_default_frequency_slot); +} + +static void test_loraRegionChange_preservesConcreteDefaultSlotProvenance() +{ + configureConcreteDefaultSlot("Region Primary"); + auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_EU_868, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + candidate.channel_num = config.lora.channel_num; + + sendSetLora(candidate); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(0, scriptedRadio->pending()->candidate.channel_num); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + TEST_ASSERT_TRUE(RadioInterface::uses_default_frequency_slot); +} + +static void test_loraPresetChange_preservesExplicitSlot() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + config.lora.channel_num = 2; + RadioInterface::uses_default_frequency_slot = false; + auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + candidate.channel_num = 2; + + sendSetLora(candidate); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(2, scriptedRadio->pending()->candidate.channel_num); + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + TEST_ASSERT_FALSE(RadioInterface::uses_default_frequency_slot); +} + +static void test_editTransaction_loraThenChannelPreservesConcreteDefaultSlot() +{ + configureConcreteDefaultSlot("Old Primary"); + auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + candidate.channel_num = config.lora.channel_num; + + sendBeginEdit(); + sendSetLora(candidate); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "New Primary", DEFAULT_KEY, sizeof(DEFAULT_KEY))); + sendCommitEdit(); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(0, scriptedRadio->pending()->candidate.channel_num); +} + +static void test_editTransaction_channelThenLoraPreservesConcreteDefaultSlot() +{ + configureConcreteDefaultSlot("Old Primary"); + auto candidate = + makeLoRaCandidate(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + candidate.channel_num = config.lora.channel_num; + + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "New Primary", DEFAULT_KEY, sizeof(DEFAULT_KEY))); + sendSetLora(candidate); + sendCommitEdit(); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(0, scriptedRadio->pending()->candidate.channel_num); +} + +static void test_editTransaction_primaryReplacementIsWriteOrderIndependent() +{ + const auto oldPrimaryDisabled = makeChannel(0, meshtastic_Channel_Role_DISABLED, "", CUSTOM_KEY, 0); + const auto newPrimary = makeChannel(1, meshtastic_Channel_Role_PRIMARY, "Replacement", CUSTOM_KEY, sizeof(CUSTOM_KEY)); + const auto initialSecondary = + makeChannel(1, meshtastic_Channel_Role_SECONDARY, "Replacement", CUSTOM_KEY, sizeof(CUSTOM_KEY)); + + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + sendSetChannel(initialSecondary); + sendBeginEdit(); + sendSetChannel(oldPrimaryDisabled); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, readChannel(0).role); + sendSetChannel(newPrimary); + sendCommitEdit(); + completeQueuedChannelApply(); + const meshtastic_ChannelFile disableThenPromote = channelFile; + + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + sendSetChannel(initialSecondary); + sendBeginEdit(); + sendSetChannel(newPrimary); + sendSetChannel(oldPrimaryDisabled); + sendCommitEdit(); + completeQueuedChannelApply(); + + TEST_ASSERT_EQUAL_MEMORY(&disableThenPromote, &channelFile, sizeof(channelFile)); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channelFile.channels[0].role); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_PRIMARY, channelFile.channels[1].role); +} + +static void test_editTransaction_primaryReplacementFailureRetainsPublishedTable() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "Replacement", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + const meshtastic_ChannelFile published = channelFile; + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_DISABLED, "", CUSTOM_KEY, 0)); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_PRIMARY, "Replacement", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + sendCommitEdit(); + + scriptedRadio->complete(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK); + mockMeshService->loop(); + TEST_ASSERT_EQUAL_MEMORY(&published, &channelFile, sizeof(channelFile)); +} + +static void test_channelApplyFailureWithLicensedOwnerKeepsChannelsUnencrypted() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_GREATER_THAN_UINT32(0, channels.getPrimary().psk.size); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Licensed", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + sendCommitEdit(); + + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + owner.is_licensed = true; + scriptedRadio->complete(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK); + mockMeshService->loop(); + + TEST_ASSERT_TRUE(owner.is_licensed); + TEST_ASSERT_EQUAL_UINT32(0, channels.getPrimary().psk.size); + TEST_ASSERT_TRUE(testAdmin->persistedSegments() & SEGMENT_CHANNELS); +} + +static void configureLicensedSinglePrimary() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + owner.is_licensed = true; + channels.ensureLicensedOperation(); + TEST_ASSERT_EQUAL_UINT32(0, channels.getPrimary().psk.size); +} + +static void assertLicensedFallbackPrimaryIsPlaintext() +{ + completeQueuedChannelApply(); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_PRIMARY, channelFile.channels[0].role); + TEST_ASSERT_EQUAL_UINT32(0, channelFile.channels[0].settings.psk.size); +} + +static void test_setChannel_licensedFallbackPrimaryRemainsPlaintext() +{ + configureLicensedSinglePrimary(); + + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_DISABLED, "", CUSTOM_KEY, 0)); + + assertLicensedFallbackPrimaryIsPlaintext(); +} + +static void test_editTransaction_licensedFallbackPrimaryRemainsPlaintext() +{ + configureLicensedSinglePrimary(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_DISABLED, "", CUSTOM_KEY, 0)); + sendCommitEdit(); + + assertLicensedFallbackPrimaryIsPlaintext(); +} + +static void test_editTransaction_expiredLicensedFallbackPrimaryRemainsPlaintext() +{ + configureLicensedSinglePrimary(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_DISABLED, "", CUSTOM_KEY, 0)); + testAdmin->ageEditTransaction(); + sendGetDeviceMetadata(); + + assertLicensedFallbackPrimaryIsPlaintext(); +} + +static void test_editTransaction_primaryChannelDefersAndReconfiguresOnce() +{ + usePresetLongFast(); + auto radio = std::make_unique(); + auto *channelRadio = radio.get(); + scriptedRadio = nullptr; + testRouter->addInterface(std::move(radio)); + channelRadio->beginSendingForTest(); + + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "LongFast", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + sendCommitEdit(); + channelRadio->serviceConfigApply(millis()); + const uint32_t whileSending = channelRadio->reconfigurations(); + + channelRadio->completeSendingForTest(); + channelRadio->serviceConfigApply(millis()); + mockMeshService->loop(); + + const uint32_t afterCompletion = channelRadio->reconfigurations(); + auto replacement = std::make_unique(); + scriptedRadio = replacement.get(); + testRouter->addInterface(std::move(replacement)); + + TEST_ASSERT_EQUAL_UINT32(0, whileSending); + TEST_ASSERT_EQUAL_UINT32(1, afterCompletion); +} + +static void test_editTransaction_secondaryOnlyDoesNotReconfigureRadio() +{ + usePresetLongFast(); + ConfigChangedCounter configChanges; + configChanges.observe(&mockMeshService->configChanged); + + sendBeginEdit(); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "Private", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + sendCommitEdit(); + + TEST_ASSERT_EQUAL_UINT32(0, configChanges.count); + TEST_ASSERT_EQUAL_UINT32(0, scriptedRadio->requests()); +} + +static void test_editTransaction_abandonedPrimaryQueuesSafeApply() +{ + configureLoRaTransactionBaseline(meshtastic_Config_LoRaConfig_RegionCode_US); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "Expired", CUSTOM_KEY, sizeof(CUSTOM_KEY))); + TEST_ASSERT_NULL(scriptedRadio->pending()); + + testAdmin->ageEditTransaction(); + sendGetDeviceMetadata(); + + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + TEST_ASSERT_NOT_NULL(scriptedRadio->pending()); + TEST_ASSERT_EQUAL_UINT32(0, testAdmin->persistenceCount()); + + scriptedRadio->complete(RadioConfigApplyResult::APPLIED); + mockMeshService->loop(); + + TEST_ASSERT_EQUAL_STRING("Expired", channels.getPrimary().name); + TEST_ASSERT_EQUAL_INT(SEGMENT_CHANNELS, testAdmin->persistedSegments()); +} + +static void test_warn_singleChannel_nameAndPsk_collapsedToCatchAll() +{ + usePresetLongFast(); + // Variant name AND a non-default key: two issues on one channel collapse to one catch-all. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", CUSTOM_KEY, 2)); + completeQueuedChannelApply(); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name and PSK issues on channel 0")); +} + +static void test_warn_cleanChannel_noMessage() +{ + usePresetLongFast(); + // Exact preset name + default key: nothing to warn about. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "LongFast", DEFAULT_KEY, 1)); + completeQueuedChannelApply(); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); +} + +static void test_warn_transaction_multipleChannels_singleCoalescedMessage() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1)); + // Nothing emitted yet - warnings are deferred until commit. + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + completeQueuedChannelApply(); + // Exactly one message, naming both channels. + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1")); +} + +static void test_warn_transaction_singleChannel_keepsSpecificMessage() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + completeQueuedChannelApply(); + // One flagged channel: the specific message verbatim, not the plural catch-all. + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); + TEST_ASSERT_EQUAL_INT(0, warningsContaining("on channels")); +} + +// An idle transaction is retired by the next admin message, flushing the warnings it held. +static void test_editTransaction_abandoned_isRetiredOnNextAdminMessage() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + // Deferred, exactly as before: nothing emitted while the transaction looks alive. + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + TEST_ASSERT_TRUE(testAdmin->editTransactionOpen()); + + testAdmin->ageEditTransaction(); + sendGetDeviceMetadata(); // any later admin message, from any client + completeQueuedChannelApply(); + + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); +} + +// A write arriving after abandonment is saved, not deferred to a commit that never comes. +static void test_editTransaction_abandoned_laterWriteIsNoLongerDeferred() +{ + usePresetLongFast(); + sendBeginEdit(); + testAdmin->ageEditTransaction(); + + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + completeQueuedChannelApply(); + + // The write itself retired the stale transaction, so its own warning is emitted immediately. + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); +} + +// A transaction still in use is left alone: each write refreshes the window. +static void test_editTransaction_active_isNotRetired() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1)); + + TEST_ASSERT_TRUE(testAdmin->editTransactionOpen()); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + completeQueuedChannelApply(); + TEST_ASSERT_FALSE(testAdmin->editTransactionOpen()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1")); +} + +static void test_warn_license_noTransaction_emittedImmediately() +{ + usePresetLongFast(); + owner.is_licensed = true; + // Setting a channel that still carries a key triggers ensureLicensedOperation() to strip it. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2)); + completeQueuedChannelApply(); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated")); +} + +static void test_warn_license_transaction_coalescedToSingleMessage() +{ + usePresetLongFast(); + owner.is_licensed = true; + sendBeginEdit(); + // Two separate triggers within one transaction (two channels with keys to strip). + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2)); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "", CUSTOM_KEY, 2)); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + completeQueuedChannelApply(); + // Collapsed to a single licensed-mode notice (and no channel warning, since names are blank). + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated")); TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); } @@ -1730,7 +3690,17 @@ void setUp(void) { mockMeshService = new MockMeshService(); service = mockMeshService; + router = testRouter; + if (!scriptedRadio) { + auto replacement = std::make_unique(); + scriptedRadio = replacement.get(); + testRouter->addInterface(std::move(replacement)); + } + scriptedRadio->reset(); + + savedAdminModule = adminModule; testAdmin = new AdminModuleTestShim(); + adminModule = testAdmin; capturedWarnings.clear(); // Committing an edit transaction triggers a full saveToDisk(), which dereferences nodeDB. // Create it once (kept reachable via the global, so no leak) for the warning tests; the @@ -1740,7 +3710,10 @@ void setUp(void) } void tearDown(void) { + testAdmin->drainReply(); restoreAdminRadioGlobals(); + adminModule = savedAdminModule; + router = savedRouter; service = nullptr; delete mockMeshService; mockMeshService = nullptr; @@ -1754,11 +3727,25 @@ void setup() delay(2000); initializeTestEnvironment(); + if (!powerMon) + powerMonInit(); + portduino_config.has_gps = true; + if (!gps) + gps = GPS::createGps(); + TEST_ASSERT_NOT_NULL(gps.get()); + gps->disable(); + initRegion(); + savedRouter = router; + testRouter = new Router(); + auto radio = std::make_unique(); + scriptedRadio = radio.get(); + testRouter->addInterface(std::move(radio)); UNITY_BEGIN(); // getRegion() RUN_TEST(test_handleSetOwner_persistsLicensedChannelSanitation); + RUN_TEST(test_handleSetOwner_radioFailureKeepsLicenseAndChannelsUnchanged); RUN_TEST(test_handleSetConfig_persistsLicensedFirstRegionIdentity); RUN_TEST(test_bootDefense_sanitizesStaleLicensedChannelsOnce); RUN_TEST(test_restorePreferences_sanitizesLicensedBackupBeforeReturn); @@ -1840,6 +3827,10 @@ void setup() RUN_TEST(test_clampBandwidthCode_zeroMapsToDefaultOthersUnchanged); RUN_TEST(test_handleSetConfig_fromLocal_customBandwidthZeroClampedToDefault); RUN_TEST(test_handleSetConfig_fromOthers_customBandwidthZeroClampedToDefault); + RUN_TEST(test_handleSetConfig_localUnsupportedLr1121BandwidthUsesRegionDefault); + RUN_TEST(test_handleSetConfig_remoteUnsupportedLr1121BandwidthIsRejected); + RUN_TEST(test_handleSetConfig_crossRegionLocalUnsupportedLr1121BandwidthUsesRegionDefault); + RUN_TEST(test_handleSetConfig_crossRegionRemoteUnsupportedLr1121BandwidthIsRejected); RUN_TEST(test_handleSetConfig_fromLocal_presetBandwidthZeroLeftUntouched); RUN_TEST(test_handleSetConfig_fromLocal_customBandwidthNonZeroPreserved); RUN_TEST(test_handleSetConfig_security_preservesKeypairWhenPrivateOmitted); @@ -1852,7 +3843,74 @@ void setup() RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion); RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected); + // Asynchronous LoRa config transaction + RUN_TEST(test_editTransaction_loraCandidate_staysInactiveUntilCommit); + RUN_TEST(test_editTransaction_commit_queuesOneRadioApply); + RUN_TEST(test_editTransaction_expiryQueuesAndPersistsStagedLora); + RUN_TEST(test_editTransaction_loraFailure_savesOnlyUnrelatedSegments); + RUN_TEST(test_menuLoRaHelper_ordinaryQueueSuccessBusyAndNoSyncPersistence); + RUN_TEST(test_menuLoRaHelper_queueRejectionDiscardsLicensedTransitions); + RUN_TEST(test_menuLoRaHelper_busyDiscardsLicensedTransitions); + RUN_TEST(test_menuLoRaHelper_enterLicensedTerminalFailuresLeaveSnapshotUnchanged); + RUN_TEST(test_menuLoRaHelper_exitLicensedTerminalFailuresLeaveSnapshotUnchanged); + RUN_TEST(test_menuLoRaHelper_enterLicensedAppliesOnceAfterRadioSuccess); + RUN_TEST(test_menuLoRaHelper_exitLicensedAppliesNormalizedStateOnceAfterRadioSuccess); + RUN_TEST(test_setHamMode_directProtobufPathCommitsAfterRadioSuccess); + RUN_TEST(test_setHamMode_directProtobufFailureLeavesStateUnchanged); + RUN_TEST(test_setLoraConfig_invalidRegionRejectedBeforeTransaction); + RUN_TEST(test_setLoraConfig_remoteInvalidRejectedBeforeTransaction); + RUN_TEST(test_setLoraConfig_doesNotMutateOrSaveBeforeHardwareSuccess); + RUN_TEST(test_setLoraConfig_normalizationSideEffectsWaitForHardwareSuccess); + RUN_TEST(test_setLoraConfig_success_appliesSideEffectsThenPersists); + RUN_TEST(test_setLoraConfig_failure_keepsOldConfigAndWarns); + RUN_TEST(test_setLoraConfig_interfaceReplacementKeepsOldConfig); + RUN_TEST(test_setLoraConfig_concurrentSavePersistsAfterTerminalResult); + RUN_TEST(test_editTransaction_busyCommitPreservesStagedLoraForRetry); + RUN_TEST(test_setLoraConfig_deferredSaveWaitsForRadioFinalization); + RUN_TEST(test_setLoraConfig_rollbackFailure_keepsPersistedConfigAndWarnsRecoveryFailure); + RUN_TEST(test_setLoraConfig_busy_returnsBadRequest); + RUN_TEST(test_destructiveAdminOperations_rejectedDuringPendingRadioApply); + RUN_TEST(test_powerCommands_dueBeforeRadioApply_waitForCompletion); + RUN_TEST(test_setLoraConfig_regionSideEffects_doNotRunBeforeSuccess); + // Channel-configuration warning + coalescing + RUN_TEST(test_setPrimaryChannel_activeTxDefersRadioApplyUntilSendingCompletes); + RUN_TEST(test_setSecondaryChannel_doesNotReconfigureRadio); + RUN_TEST(test_setPrimaryChannel_applyFailureRestoresPreviousChannels); + RUN_TEST(test_setPrimaryChannel_queueFailureDoesNotLeakCandidateWarnings); + RUN_TEST(test_setChannel_duringPendingApplyIsRejectedWithoutOrphaningFirstRequest); + RUN_TEST(test_beginEdit_duringPendingApplyIsRejected); + RUN_TEST(test_editTransaction_channelThenLoraPreservesBothInApply); + RUN_TEST(test_editTransaction_loraThenChannelPreservesBothInApply); + RUN_TEST(test_editTransaction_getChannelReturnsStagedCandidate); + RUN_TEST(test_editTransaction_defaultChannelHashUsesCandidatePreset); + RUN_TEST(test_editTransaction_abandonedLicenseExitQueuesRadioApply); + RUN_TEST(test_editTransaction_loraThenOwnerPreservesBothCandidates); + RUN_TEST(test_editTransaction_ownerThenLoraPreservesBothCandidates); + RUN_TEST(test_editTransaction_channelThenLoraCorrectsInvalidSlotAgainstFinalPrimary); + RUN_TEST(test_editTransaction_loraThenChannelCorrectsInvalidSlotAgainstFinalPrimary); + RUN_TEST(test_editTransaction_expiryCorrectsInvalidSlotAgainstFinalPrimary); + RUN_TEST(test_editTransaction_invalidSlotRollbackKeepsPublishedState); + RUN_TEST(test_editTransaction_ownerLastPreservesCorrectedSlotProvenance); + RUN_TEST(test_editTransaction_compoundCorrectionTracksFinalPrimary); + RUN_TEST(test_channelRename_preservesConcreteDefaultSlotProvenance); + RUN_TEST(test_channelRename_preservesZeroDefaultSlotProvenance); + RUN_TEST(test_channelRename_preservesExplicitSlot); + RUN_TEST(test_channelRename_rollbackPreservesDefaultSlotState); + RUN_TEST(test_loraPresetChange_preservesConcreteDefaultSlotProvenance); + RUN_TEST(test_loraRegionChange_preservesConcreteDefaultSlotProvenance); + RUN_TEST(test_loraPresetChange_preservesExplicitSlot); + RUN_TEST(test_editTransaction_loraThenChannelPreservesConcreteDefaultSlot); + RUN_TEST(test_editTransaction_channelThenLoraPreservesConcreteDefaultSlot); + RUN_TEST(test_editTransaction_primaryReplacementIsWriteOrderIndependent); + RUN_TEST(test_editTransaction_primaryReplacementFailureRetainsPublishedTable); + RUN_TEST(test_channelApplyFailureWithLicensedOwnerKeepsChannelsUnencrypted); + RUN_TEST(test_setChannel_licensedFallbackPrimaryRemainsPlaintext); + RUN_TEST(test_editTransaction_licensedFallbackPrimaryRemainsPlaintext); + RUN_TEST(test_editTransaction_expiredLicensedFallbackPrimaryRemainsPlaintext); + RUN_TEST(test_editTransaction_primaryChannelDefersAndReconfiguresOnce); + RUN_TEST(test_editTransaction_secondaryOnlyDoesNotReconfigureRadio); + RUN_TEST(test_editTransaction_abandonedPrimaryQueuesSafeApply); RUN_TEST(test_warn_singleChannel_variantName_oneSpecificMessage); RUN_TEST(test_warn_singleChannel_nameAndPsk_collapsedToCatchAll); RUN_TEST(test_warn_cleanChannel_noMessage); diff --git a/test/test_http_content_handler/test_main.cpp b/test/test_http_content_handler/test_main.cpp index 3b628a2b218..b4341c7e813 100644 --- a/test/test_http_content_handler/test_main.cpp +++ b/test/test_http_content_handler/test_main.cpp @@ -1,18 +1,264 @@ #include "TestUtil.h" +#if defined(ARCH_PORTDUINO) && __has_include() +#include "mesh/raspihttp/PiWebServer.h" +#include "support/MockMeshService.h" +#include +#include +#include +#include +#include +#include +#endif #include #include +#if defined(ARCH_PORTDUINO) && __has_include() +class RecordingHttpAPI : public HttpAPI +{ + public: + bool handleToRadio(const uint8_t *buf, size_t len) override + { + std::lock_guard guard(recordMutex); + dispatchThread = std::this_thread::get_id(); + values.push_back(len ? buf[0] : 0); + return true; + } + + std::mutex recordMutex; + std::thread::id dispatchThread; + std::vector values; +}; + +class BlockingHttpAPI : public RecordingHttpAPI +{ + public: + explicit BlockingHttpAPI(std::shared_future release) : release(release) {} + + bool handleToRadio(const uint8_t *buf, size_t len) override + { + dispatchEntered.set_value(); + release.wait(); + return RecordingHttpAPI::handleToRadio(buf, len); + } + + std::promise dispatchEntered; + + private: + std::shared_future release; +}; + +static MockMeshService *mockMeshService; + +void setUp() +{ + mockMeshService = new MockMeshService(); + service = mockMeshService; +} + +void tearDown() +{ + service = nullptr; + delete mockMeshService; + mockMeshService = nullptr; +} + +static bool waitForPending(HttpAPI &api, size_t count) +{ + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1); + while (api.pendingRequestCount() != count && std::chrono::steady_clock::now() < deadline) + std::this_thread::yield(); + return api.pendingRequestCount() == count; +} + +static void test_toRadioSubmissionWaitsForMainPump() +{ + RecordingHttpAPI api; + std::promise submitThreadPromise; + auto submitThread = submitThreadPromise.get_future(); + uint8_t payload[] = {42}; + auto submitted = std::async(std::launch::async, [&] { + submitThreadPromise.set_value(std::this_thread::get_id()); + return api.submitToRadio(payload, sizeof(payload)); + }); + const std::thread::id workerThread = submitThread.get(); + + const bool pending = waitForPending(api, 1); + const auto beforePump = submitted.wait_for(std::chrono::milliseconds(0)); + if (pending) + api.processPendingRequests(); + else + api.stopAcceptingRequests(); + const bool result = submitted.get(); + + TEST_ASSERT_TRUE(pending); + TEST_ASSERT_EQUAL(std::future_status::timeout, beforePump); + TEST_ASSERT_TRUE(result); + TEST_ASSERT_EQUAL_UINT8(1, api.values.size()); + TEST_ASSERT_EQUAL_UINT8(42, api.values[0]); + TEST_ASSERT_FALSE(api.dispatchThread == workerThread); +} + +static void test_toRadioRequestsRemainFifo() +{ + RecordingHttpAPI api; + uint8_t first[] = {1}; + uint8_t second[] = {2}; + auto firstResult = std::async(std::launch::async, [&] { return api.submitToRadio(first, sizeof(first)); }); + const bool firstPending = waitForPending(api, 1); + auto secondResult = std::async(std::launch::async, [&] { return api.submitToRadio(second, sizeof(second)); }); + const bool bothPending = waitForPending(api, 2); + if (bothPending) + api.processPendingRequests(); + else + api.stopAcceptingRequests(); + const bool firstAccepted = firstResult.get(); + const bool secondAccepted = secondResult.get(); + + TEST_ASSERT_TRUE(firstPending); + TEST_ASSERT_TRUE(bothPending); + TEST_ASSERT_TRUE(firstAccepted); + TEST_ASSERT_TRUE(secondAccepted); + TEST_ASSERT_EQUAL_UINT8(2, api.values.size()); + TEST_ASSERT_EQUAL_UINT8(1, api.values[0]); + TEST_ASSERT_EQUAL_UINT8(2, api.values[1]); +} + +static void test_toRadioQueueRejectsOverflowWithoutOverwriting() +{ + RecordingHttpAPI api; + std::vector> results; + uint8_t payloads[9] = {}; + for (uint8_t i = 0; i < 8; ++i) { + payloads[i] = i; + results.push_back(std::async(std::launch::async, [&, i] { return api.submitToRadio(&payloads[i], 1); })); + } + const bool queueFull = waitForPending(api, 8); + bool overflowAccepted = false; + if (queueFull) { + auto overflow = std::async(std::launch::async, [&] { return api.submitToRadio(&payloads[8], 1); }); + overflowAccepted = overflow.get(); + api.processPendingRequests(); + } else { + api.stopAcceptingRequests(); + } + + uint8_t accepted = 0; + for (auto &result : results) + accepted += result.get() ? 1 : 0; + TEST_ASSERT_TRUE(queueFull); + TEST_ASSERT_FALSE(overflowAccepted); + TEST_ASSERT_EQUAL_UINT8(8, accepted); + TEST_ASSERT_EQUAL_UINT8(8, api.values.size()); +} + +static void test_shutdownCancelsQueuedRequest() +{ + RecordingHttpAPI api; + uint8_t payload[] = {7}; + auto submitted = std::async(std::launch::async, [&] { return api.submitToRadio(payload, sizeof(payload)); }); + const bool pending = waitForPending(api, 1); + + api.stopAcceptingRequests(); + const bool result = submitted.get(); + + TEST_ASSERT_TRUE(pending); + TEST_ASSERT_FALSE(result); + TEST_ASSERT_TRUE(api.values.empty()); +} + +static void test_shutdownWaitsForInFlightRequest() +{ + std::promise releaseDispatch; + BlockingHttpAPI api(releaseDispatch.get_future().share()); + auto dispatchEntered = api.dispatchEntered.get_future(); + uint8_t payload[] = {9}; + auto submitted = std::async(std::launch::async, [&] { return api.submitToRadio(payload, sizeof(payload)); }); + const bool pending = waitForPending(api, 1); + std::future pump; + std::future_status entered = std::future_status::timeout; + if (pending) { + pump = std::async(std::launch::async, [&] { api.processPendingRequests(); }); + entered = dispatchEntered.wait_for(std::chrono::seconds(1)); + } + + auto stopping = std::async(std::launch::async, [&] { api.stopAcceptingRequests(); }); + const auto stopBeforeRelease = stopping.wait_for(std::chrono::milliseconds(50)); + releaseDispatch.set_value(); + if (pump.valid()) + pump.get(); + stopping.get(); + const bool result = submitted.get(); + + TEST_ASSERT_TRUE(pending); + TEST_ASSERT_EQUAL(std::future_status::ready, entered); + TEST_ASSERT_EQUAL(std::future_status::timeout, stopBeforeRelease); + TEST_ASSERT_TRUE(result); +} + +static void test_submissionTimesOutWithoutMainPump() +{ + RecordingHttpAPI api; + uint8_t payload[] = {11}; + const auto started = std::chrono::steady_clock::now(); + const bool result = api.submitToRadio(payload, sizeof(payload)); + const auto elapsed = std::chrono::steady_clock::now() - started; + + TEST_ASSERT_FALSE(result); + TEST_ASSERT_TRUE(elapsed >= std::chrono::milliseconds(4500)); + TEST_ASSERT_EQUAL_UINT32(0, api.pendingRequestCount()); +} + +static void test_fromRadioSubmissionWaitsForMainPump() +{ + RecordingHttpAPI api; + uint8_t payload[MAX_TO_FROM_RADIO_SIZE] = {}; + size_t length = sizeof(payload); + auto submitted = std::async(std::launch::async, [&] { return api.submitFromRadio(payload, length); }); + const bool pending = waitForPending(api, 1); + const auto beforePump = submitted.wait_for(std::chrono::milliseconds(0)); + if (pending) + api.processPendingRequests(); + else + api.stopAcceptingRequests(); + const bool result = submitted.get(); + + TEST_ASSERT_TRUE(pending); + TEST_ASSERT_EQUAL(std::future_status::timeout, beforePump); + TEST_ASSERT_TRUE(result); + TEST_ASSERT_EQUAL_UINT32(0, length); +} + +static void test_invalidToRadioSizesAreRejected() +{ + RecordingHttpAPI api; + uint8_t payload[MAX_TO_FROM_RADIO_SIZE + 1] = {}; + TEST_ASSERT_FALSE(api.submitToRadio(payload, 0)); + TEST_ASSERT_FALSE(api.submitToRadio(payload, sizeof(payload))); +} +#else static void test_placeholder() { TEST_ASSERT_TRUE(true); } +#endif extern "C" { void setup() { initializeTestEnvironment(); UNITY_BEGIN(); +#if defined(ARCH_PORTDUINO) && __has_include() + RUN_TEST(test_toRadioSubmissionWaitsForMainPump); + RUN_TEST(test_toRadioRequestsRemainFifo); + RUN_TEST(test_toRadioQueueRejectsOverflowWithoutOverwriting); + RUN_TEST(test_shutdownCancelsQueuedRequest); + RUN_TEST(test_shutdownWaitsForInFlightRequest); + RUN_TEST(test_submissionTimesOutWithoutMainPump); + RUN_TEST(test_fromRadioSubmissionWaitsForMainPump); + RUN_TEST(test_invalidToRadioSizesAreRejected); +#else RUN_TEST(test_placeholder); +#endif exit(UNITY_END()); } diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index bb1a0abc2f5..a191e101b99 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -21,9 +21,11 @@ #include "MeshService.h" #include "NodeDB.h" #include "RadioInterface.h" +#include "RadioLibInterface.h" #include "airtime.h" #include "modules/AdminModule.h" #include "modules/MeshBeaconModule.h" +#include "platform/portduino/SimRadio.h" #include "support/AdminModuleTestShim.h" #include "support/MockMeshService.h" #include @@ -75,6 +77,7 @@ class MockRouter : public Router if (channelFile.channels_count > 0) primaryAtSend.push_back(channels.getByIndex(channels.getPrimaryIndex()).settings); sentPackets.push_back(*p); + MeshBeaconModule::clearTargetRadioSettings(p); packetPool.release(p); return ERRNO_OK; } @@ -113,12 +116,93 @@ class MeshBeaconListenerModuleTestShim : public MeshBeaconListenerModule using MeshBeaconListenerModule::wantPacket; }; +class ScriptedBeaconRadio : public RadioLibInterface +{ + public: + ScriptedBeaconRadio() : RadioLibInterface(nullptr, 0, 0, 0, 0) {} + + bool reconfigure() override + { + temporaryStateDuringReconfigure.push_back(MeshBeaconModule::radioConfigIsTemporary()); + reconfigureCount++; + if (reenterDuringReconfigure) { + reenterDuringReconfigure = false; + reentrantResult = MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); + } + const bool result = + scriptedResultIndex < scriptedResults.size() ? scriptedResults[scriptedResultIndex++] : nextReconfigureResult; + if (!result && shouldRecordReconfigureFailure()) + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + return result; + } + + void scriptReconfigureResults(std::initializer_list results) + { + scriptedResults = results; + scriptedResultIndex = 0; + } + + void scheduleBeaconRestoreForTest() { scheduleBeaconRestoreRetry(); } + + void serviceNotificationForTest() { checkNotification(); } + + ErrorCode enqueuePacketForTest(meshtastic_MeshPacket *packet) { return send(packet); } + + uint32_t queuedPacketCount() + { + const auto status = static_cast(this)->getQueueStatus(); + return status.maxlen - status.free; + } + + void fireTransmitDelayForTest() + { + notify(TRANSMIT_DELAY_COMPLETED, true); + checkNotification(); + } + + uint32_t getPacketTime(uint32_t, bool) override { return 0; } + void startReceive() override {} + bool wideLora() override { return true; } + bool supportsLoRaBandwidth(float bandwidthKHz, bool wideBand) override + { + if (!limitWideBandwidths) + return true; + return !wideBand || bandwidthKHz == 203.125f || bandwidthKHz == 406.25f || bandwidthKHz == 812.5f; + } + + bool startSend(meshtastic_MeshPacket *) override + { + startSendCount++; + return true; + } + + protected: + void disableInterrupt() override {} + void enableInterrupt(void (*)()) override {} + int16_t getCurrentRSSI() override { return -120; } + bool isChannelActive() override { return false; } + bool isActivelyReceiving() override { return false; } + void addReceiveMetadata(meshtastic_MeshPacket *) override {} + + public: + bool nextReconfigureResult = true; + bool reenterDuringReconfigure = false; + MeshBeaconModule::RadioConfigResult reentrantResult = MeshBeaconModule::RadioConfigResult::UNCHANGED; + uint32_t reconfigureCount = 0; + std::vector temporaryStateDuringReconfigure; + std::vector scriptedResults; + size_t scriptedResultIndex = 0; + bool limitWideBandwidths = false; + uint32_t startSendCount = 0; +}; + // --------------------------------------------------------------------------- // Globals managed by setUp / tearDown. // --------------------------------------------------------------------------- static MockMeshService *mockSvc = nullptr; static MockRouter *mockRouter = nullptr; static AdminModuleTestShim *testAdmin = nullptr; +static AdminModule *savedAdminModule = nullptr; static AirTime *testAirTime = nullptr; // --------------------------------------------------------------------------- @@ -1338,6 +1422,306 @@ static void test_broadcaster_distinctTargets_bothSent(void) TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, mockRouter->sentPackets.size(), "distinct targets must each be sent"); } +static meshtastic_MeshPacket makeTemporaryBeaconPacket() +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.id = 0x12345678; + MeshBeaconModule::setTargetRadioSettings(&packet, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, 1); + return packet; +} + +static void test_meshBeacon_rejectsPresetUnsupportedByActiveRadio() +{ + resetConfig(); + static const uint8_t homePsk[] = {1}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + ScriptedBeaconRadio radio; + radio.limitWideBandwidths = true; + const auto homeConfig = config.lora; + error_code = meshtastic_CriticalErrorCode_NONE; + error_address = 0; + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.id = 0x87654321; + TEST_ASSERT_TRUE(MeshBeaconModule::setTargetRadioSettings(&packet, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, 1, + false, meshtastic_Config_LoRaConfig_RegionCode_LORA_24)); + + const auto result = MeshBeaconModule::reconfigureForBeaconTX(&radio, &packet); + + TEST_ASSERT_EQUAL(MeshBeaconModule::RadioConfigResult::UNCHANGED, result); + TEST_ASSERT_EQUAL_UINT32(0, radio.reconfigureCount); + TEST_ASSERT_EQUAL_MEMORY(&homeConfig, &config.lora, sizeof(homeConfig)); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_NONE, error_code); + TEST_ASSERT_EQUAL_UINT32(0, error_address); + MeshBeaconModule::clearTargetRadioSettings(&packet); +} + +static void test_meshBeacon_queueDropsPresetUnsupportedByActiveRadio() +{ + resetConfig(); + config.lora.tx_enabled = true; + ScriptedBeaconRadio radio; + radio.limitWideBandwidths = true; + meshtastic_MeshPacket *packet = packetPool.allocZeroed(); + TEST_ASSERT_NOT_NULL(packet); + packet->id = 0x76543210; + TEST_ASSERT_TRUE(MeshBeaconModule::setTargetRadioSettings(packet, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, 1, + false, meshtastic_Config_LoRaConfig_RegionCode_LORA_24)); + TEST_ASSERT_EQUAL(ERRNO_OK, radio.enqueuePacketForTest(packet)); + + radio.fireTransmitDelayForTest(); + + TEST_ASSERT_EQUAL_UINT32(0, radio.startSendCount); + TEST_ASSERT_EQUAL_UINT32(0, radio.queuedPacketCount()); + meshtastic_MeshPacket probe = meshtastic_MeshPacket_init_zero; + probe.id = 0x76543210; + TEST_ASSERT_FALSE(MeshBeaconModule::hasTargetRadioSettings(&probe)); +} + +static void test_meshBeacon_pendingPersistentApply_waitsForHomeRestore() +{ + resetConfig(); + static const uint8_t homePsk[] = {1}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + ScriptedBeaconRadio radio; + const auto homeConfig = config.lora; + auto packet = makeTemporaryBeaconPacket(); + auto candidate = homeConfig; + candidate.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + RadioConfigApplyRequest request{homeConfig, candidate, static_cast(millis()), 5000}; + + TEST_ASSERT_FALSE(MeshBeaconModule::radioConfigIsTemporary()); + TEST_ASSERT_EQUAL(MeshBeaconModule::RadioConfigResult::RECONFIGURED, + MeshBeaconModule::reconfigureForBeaconTX(&radio, &packet)); + TEST_ASSERT_TRUE(MeshBeaconModule::radioConfigIsTemporary()); + TEST_ASSERT_EQUAL_UINT32(1, radio.reconfigureCount); + TEST_ASSERT_TRUE(radio.temporaryStateDuringReconfigure[0]); + + TEST_ASSERT_TRUE(radio.requestConfigApply(&request)); + radio.serviceConfigApply(millis()); + + TEST_ASSERT_FALSE(MeshBeaconModule::radioConfigIsTemporary()); + TEST_ASSERT_EQUAL(RadioConfigApplyResult::PENDING, request.result.load()); + TEST_ASSERT_EQUAL_UINT32(2, radio.reconfigureCount); + TEST_ASSERT_TRUE(radio.temporaryStateDuringReconfigure[1]); + TEST_ASSERT_EQUAL_MEMORY(&homeConfig, &config.lora, sizeof(homeConfig)); + + radio.serviceConfigApply(millis()); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::APPLIED, request.result.load()); + TEST_ASSERT_EQUAL_UINT32(3, radio.reconfigureCount); + MeshBeaconModule::clearTargetRadioSettings(&packet); +} + +static void test_meshBeacon_failedHomeRestore_remainsTemporary() +{ + resetConfig(); + static const uint8_t homePsk[] = {1}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + ScriptedBeaconRadio radio; + auto packet = makeTemporaryBeaconPacket(); + + TEST_ASSERT_EQUAL(MeshBeaconModule::RadioConfigResult::RECONFIGURED, + MeshBeaconModule::reconfigureForBeaconTX(&radio, &packet)); + radio.nextReconfigureResult = false; + TEST_ASSERT_EQUAL(MeshBeaconModule::RadioConfigResult::FAILED, MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr)); + TEST_ASSERT_TRUE(MeshBeaconModule::radioConfigIsTemporary()); + + radio.nextReconfigureResult = true; + TEST_ASSERT_EQUAL(MeshBeaconModule::RadioConfigResult::RECONFIGURED, + MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr)); + TEST_ASSERT_FALSE(MeshBeaconModule::radioConfigIsTemporary()); + MeshBeaconModule::clearTargetRadioSettings(&packet); +} + +static void test_meshBeacon_failedTargetSwitchRestoresHomeAndReportsFailure() +{ + resetConfig(); + static const uint8_t homePsk[] = {1}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + ScriptedBeaconRadio radio; + radio.scriptReconfigureResults({false, true}); + const auto homeConfig = config.lora; + const auto homeChannel = channels.getPrimary(); + auto packet = makeTemporaryBeaconPacket(); + error_code = meshtastic_CriticalErrorCode_NONE; + error_address = 0; + + TEST_ASSERT_EQUAL(MeshBeaconModule::RadioConfigResult::FAILED, MeshBeaconModule::reconfigureForBeaconTX(&radio, &packet)); + TEST_ASSERT_FALSE(MeshBeaconModule::radioConfigIsTemporary()); + TEST_ASSERT_EQUAL_UINT32(2, radio.reconfigureCount); + TEST_ASSERT_EQUAL_MEMORY(&homeConfig, &config.lora, sizeof(homeConfig)); + TEST_ASSERT_EQUAL_MEMORY(&homeChannel, &channels.getPrimary(), sizeof(homeChannel)); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_NONE, error_code); + MeshBeaconModule::clearTargetRadioSettings(&packet); +} + +static void test_meshBeacon_reentrantRestoreDefersWithoutNestedDriverCall() +{ + resetConfig(); + static const uint8_t homePsk[] = {1}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + ScriptedBeaconRadio radio; + radio.reenterDuringReconfigure = true; + auto packet = makeTemporaryBeaconPacket(); + + TEST_ASSERT_EQUAL(MeshBeaconModule::RadioConfigResult::RECONFIGURED, + MeshBeaconModule::reconfigureForBeaconTX(&radio, &packet)); + TEST_ASSERT_EQUAL(MeshBeaconModule::RadioConfigResult::IN_PROGRESS, radio.reentrantResult); + TEST_ASSERT_EQUAL_UINT32(1, radio.reconfigureCount); + TEST_ASSERT_EQUAL(MeshBeaconModule::RadioConfigResult::RECONFIGURED, + MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr)); + MeshBeaconModule::clearTargetRadioSettings(&packet); +} + +static void test_meshBeacon_failedRestoreRetriesWithEmptyQueue() +{ + resetConfig(); + static const uint8_t homePsk[] = {1}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + ScriptedBeaconRadio radio; + auto packet = makeTemporaryBeaconPacket(); + + TEST_ASSERT_EQUAL(MeshBeaconModule::RadioConfigResult::RECONFIGURED, + MeshBeaconModule::reconfigureForBeaconTX(&radio, &packet)); + radio.scriptReconfigureResults({false, true}); + radio.scheduleBeaconRestoreForTest(); + delay(30); + radio.serviceNotificationForTest(); + TEST_ASSERT_TRUE(MeshBeaconModule::radioConfigIsTemporary()); + TEST_ASSERT_TRUE(MeshBeaconModule::radioRestoreIsPending()); + + delay(30); + radio.serviceNotificationForTest(); + TEST_ASSERT_FALSE(MeshBeaconModule::radioConfigIsTemporary()); + TEST_ASSERT_FALSE(MeshBeaconModule::radioRestoreIsPending()); + MeshBeaconModule::clearTargetRadioSettings(&packet); +} + +static void test_meshBeacon_adminRollbackUsesCanonicalHomeConfig() +{ + resetConfig(); + static const uint8_t homePsk[] = {1}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + const auto homeConfig = config.lora; + auto packet = makeTemporaryBeaconPacket(); + + auto ownedRadio = std::make_unique(); + ScriptedBeaconRadio *radio = ownedRadio.get(); + mockRouter->addInterface(std::move(ownedRadio)); + TEST_ASSERT_EQUAL(MeshBeaconModule::RadioConfigResult::RECONFIGURED, + MeshBeaconModule::reconfigureForBeaconTX(radio, &packet)); + TEST_ASSERT_TRUE(MeshBeaconModule::radioConfigIsTemporary()); + + auto candidate = homeConfig; + candidate.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + meshtastic_Config request = meshtastic_Config_init_zero; + request.which_payload_variant = meshtastic_Config_lora_tag; + request.payload_variant.lora = candidate; + TEST_ASSERT_TRUE(testAdmin->handleSetConfig(request, false)); + + radio->scriptReconfigureResults({true, false, true}); + radio->serviceConfigApply(millis()); + TEST_ASSERT_FALSE(MeshBeaconModule::radioConfigIsTemporary()); + radio->serviceConfigApply(millis()); + mockSvc->loop(); + TEST_ASSERT_TRUE(testAdmin->loRaConfigPending()); + radio->serviceConfigApply(millis()); + mockSvc->loop(); + + TEST_ASSERT_EQUAL_MEMORY(&homeConfig, &config.lora, sizeof(homeConfig)); + TEST_ASSERT_FALSE(testAdmin->loRaConfigPending()); + MeshBeaconModule::clearTargetRadioSettings(&packet); +} + +static void test_meshBeacon_queueEvictionClearsTargetMetadata() +{ + resetConfig(); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.tx_enabled = true; + ScriptedBeaconRadio radio; + PacketId ids[MAX_TX_QUEUE + 1] = {}; + + for (size_t i = 0; i < MAX_TX_QUEUE; ++i) { + meshtastic_MeshPacket *packet = packetPool.allocZeroed(); + TEST_ASSERT_NOT_NULL(packet); + ids[i] = 0x71000000U + static_cast(i); + packet->id = ids[i]; + packet->from = kLocalNode; + packet->to = NODENUM_BROADCAST; + packet->priority = meshtastic_MeshPacket_Priority_DEFAULT; + TEST_ASSERT_TRUE(MeshBeaconModule::setTargetRadioSettings(packet, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + static_cast(i + 1))); + TEST_ASSERT_EQUAL(ERRNO_OK, radio.send(packet)); + } + + meshtastic_MeshPacket *replacement = packetPool.allocZeroed(); + TEST_ASSERT_NOT_NULL(replacement); + ids[MAX_TX_QUEUE] = 0x7100ffffU; + replacement->id = ids[MAX_TX_QUEUE]; + replacement->from = kLocalNode; + replacement->to = NODENUM_BROADCAST; + replacement->priority = meshtastic_MeshPacket_Priority_ACK; + TEST_ASSERT_TRUE( + MeshBeaconModule::setTargetRadioSettings(replacement, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 1)); + TEST_ASSERT_EQUAL(ERRNO_OK, radio.send(replacement)); + + size_t liveMetadata = 0; + for (PacketId id : ids) { + meshtastic_MeshPacket probe = meshtastic_MeshPacket_init_zero; + probe.id = id; + liveMetadata += MeshBeaconModule::hasTargetRadioSettings(&probe) ? 1 : 0; + } + TEST_ASSERT_EQUAL_UINT32(MAX_TX_QUEUE, liveMetadata); + + for (PacketId id : ids) + radio.cancelSending(kLocalNode, id); + for (PacketId id : ids) { + meshtastic_MeshPacket probe = meshtastic_MeshPacket_init_zero; + probe.id = id; + TEST_ASSERT_FALSE(MeshBeaconModule::hasTargetRadioSettings(&probe)); + } +} + +static void test_meshBeacon_simRadioCompletionAndCancellationReleaseMetadata() +{ + resetConfig(); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.tx_enabled = true; + SimRadio radio; + + for (size_t i = 0; i < MAX_TX_QUEUE + 3; ++i) { + meshtastic_MeshPacket *packet = packetPool.allocZeroed(); + TEST_ASSERT_NOT_NULL(packet); + packet->id = 0x72000000U + static_cast(i); + packet->from = kLocalNode; + packet->to = NODENUM_BROADCAST; + TEST_ASSERT_TRUE(MeshBeaconModule::setTargetRadioSettings(packet, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + static_cast(i + 1))); + TEST_ASSERT_EQUAL(ERRNO_OK, radio.send(packet)); + TEST_ASSERT_TRUE(radio.completeNextSendingForTest()); + + meshtastic_MeshPacket probe = meshtastic_MeshPacket_init_zero; + probe.id = 0x72000000U + static_cast(i); + TEST_ASSERT_FALSE(MeshBeaconModule::hasTargetRadioSettings(&probe)); + } + + for (size_t i = 0; i < MAX_TX_QUEUE + 3; ++i) { + meshtastic_MeshPacket *packet = packetPool.allocZeroed(); + TEST_ASSERT_NOT_NULL(packet); + packet->id = 0x73000000U + static_cast(i); + packet->from = kLocalNode; + packet->to = NODENUM_BROADCAST; + TEST_ASSERT_TRUE(MeshBeaconModule::setTargetRadioSettings(packet, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + static_cast(i + 1))); + TEST_ASSERT_EQUAL(ERRNO_OK, radio.send(packet)); + TEST_ASSERT_TRUE(radio.cancelSending(kLocalNode, packet->id)); + + meshtastic_MeshPacket probe = meshtastic_MeshPacket_init_zero; + probe.id = 0x73000000U + static_cast(i); + TEST_ASSERT_FALSE(MeshBeaconModule::hasTargetRadioSettings(&probe)); + } +} + } // namespace // =========================================================================== @@ -1355,13 +1739,16 @@ void setUp(void) mockRouter = new MockRouter(); router = mockRouter; + savedAdminModule = adminModule; testAdmin = new AdminModuleTestShim(); + adminModule = testAdmin; } void tearDown(void) { meshBeaconBroadcastModule = nullptr; + adminModule = savedAdminModule; delete testAdmin; testAdmin = nullptr; @@ -1461,6 +1848,16 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_broadcaster_targetChannelIndex_blankSlotFallsBackToPreset); RUN_TEST(test_broadcaster_duplicateTargets_dedupedToOnePacket); RUN_TEST(test_broadcaster_distinctTargets_bothSent); + RUN_TEST(test_meshBeacon_rejectsPresetUnsupportedByActiveRadio); + RUN_TEST(test_meshBeacon_queueDropsPresetUnsupportedByActiveRadio); + RUN_TEST(test_meshBeacon_pendingPersistentApply_waitsForHomeRestore); + RUN_TEST(test_meshBeacon_failedHomeRestore_remainsTemporary); + RUN_TEST(test_meshBeacon_failedTargetSwitchRestoresHomeAndReportsFailure); + RUN_TEST(test_meshBeacon_reentrantRestoreDefersWithoutNestedDriverCall); + RUN_TEST(test_meshBeacon_failedRestoreRetriesWithEmptyQueue); + RUN_TEST(test_meshBeacon_adminRollbackUsesCanonicalHomeConfig); + RUN_TEST(test_meshBeacon_queueEvictionClearsTargetMetadata); + RUN_TEST(test_meshBeacon_simRadioCompletionAndCancellationReleaseMetadata); exit(UNITY_END()); } diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index df244216411..e34b38746c7 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -1,13 +1,25 @@ +#include "DisplayFormatters.h" +#include "LR11x0ConfigApply.h" +#include "LR11x0Interface.h" +#include "LR20x0Interface.h" #include "MeshRadio.h" #include "MeshService.h" +#include "NodeDB.h" #include "RadioInterface.h" +#include "RadioLibInterface.h" +#include "SX1280Interface.h" #include "TestUtil.h" +#include "airtime.h" +#include "error.h" #include #include "meshtastic/config.pb.h" #include "support/MockMeshService.h" +extern uint32_t hash(const char *str); + static MockMeshService *mockMeshService; +static AirTime *testAirTime; // Test shim to expose protected radio parameters set by applyModemConfig() class TestableRadioInterface : public RadioInterface @@ -17,13 +29,188 @@ class TestableRadioInterface : public RadioInterface uint8_t getCr() const { return cr; } uint8_t getSf() const { return sf; } float getBw() const { return bw; } + int8_t getPower() const { return power; } + void emulateLr1121Bandwidths() { limitWideBandwidth = true; } + void emulateSubGhzOnly() { canUseWideBand = false; } + void emulateWideOnly() + { + canUseSubGhz = false; + wideOnlyBandwidths = true; + } + + bool wideLora() override { return canUseWideBand; } + bool supportsSubGhz() override { return canUseSubGhz; } + + bool supportsLoRaBandwidth(float bandwidthKHz, bool wideBand) override + { + if (wideOnlyBandwidths) + return wideBand && + (bandwidthKHz == 203.125f || bandwidthKHz == 406.25f || bandwidthKHz == 812.5f || bandwidthKHz == 1625.0f); + return !limitWideBandwidth || !wideBand || bandwidthKHz == 203.125f || bandwidthKHz == 406.25f || bandwidthKHz == 812.5f; + } - // Override reconfigure to call the base which invokes applyModemConfig() - bool reconfigure() override { return RadioInterface::reconfigure(); } + // Override reconfigure to call the base which invokes applyModemConfig(). + bool reconfigure() override + { + RadioInterface::reconfigure(); + const size_t index = reconfigureCount < 2 ? reconfigureCount : 1; + ++reconfigureCount; + return reconfigureResults[index]; + } + + void scriptApply(bool applyResult, bool rollbackResult) + { + reconfigureResults[0] = applyResult; + reconfigureResults[1] = rollbackResult; + reconfigureCount = 0; + } // Stubs for pure virtual methods required by RadioInterface uint32_t getPacketTime(uint32_t, bool) override { return 0; } ErrorCode send(meshtastic_MeshPacket *p) override { return ERRNO_OK; } + + private: + bool reconfigureResults[2] = {true, true}; + size_t reconfigureCount = 0; + bool limitWideBandwidth = false; + bool canUseWideBand = true; + bool canUseSubGhz = true; + bool wideOnlyBandwidths = false; +}; + +class TestableRadioLibInterface : public RadioLibInterface +{ + public: + TestableRadioLibInterface() : RadioLibInterface(nullptr, 0, 0, 0, 0) {} + + bool wideLora() override { return true; } + + void setSendingForTest(bool sending) { sendingPacket = sending ? &inFlightPacket : nullptr; } + + void finishTxForTest() { sendingPacket = nullptr; } + bool sendingForTest() const { return sendingPacket != nullptr; } + + ErrorCode enqueuePacketForTest(meshtastic_MeshPacket *packet) { return send(packet); } + + uint32_t queuedPacketCount() + { + const auto status = static_cast(this)->getQueueStatus(); + return status.maxlen - status.free; + } + + void fireTransmitDelayForTest() + { + notify(TRANSMIT_DELAY_COMPLETED, true); + checkNotification(); + } + + void clearPendingNotificationForTest() { checkNotification(); } + + void setActivelyReceivingForTest(bool receiving) { activelyReceiving = receiving; } + + void scriptApply(bool applyResult, bool rollbackResult) + { + reconfigureResults[0] = applyResult; + reconfigureResults[1] = rollbackResult; + reconfigureCount = 0; + } + + void recordErrorsOnFailure() { shouldRecordErrors = true; } + + void reenterDuringNextApply() { reenterDuringApply = true; } + + void requestConfigApplyDuringNextChannelCheck(RadioConfigApplyRequest *request) { requestDuringChannelCheck = request; } + + bool requestDuringChannelCheckWasAccepted() const { return requestDuringChannelCheckAccepted; } + + void useBaseStartSendForTest(bool useBase) { useBaseStartSend = useBase; } + + uint32_t applyCount() const { return reconfigureCount; } + uint32_t startSendCount() const { return startSendCalls; } + uint32_t startReceiveCount() const { return startReceiveCalls; } + bool configApplyBarrierForTest() const { return configApplyBarrierIsSet(); } + bool configApplyReceptionHeldForTest() const { return configApplyReceptionHeld.load(); } + bool receptionWasHeldDuringApplyForTest() const { return receptionWasHeldDuringApply; } + meshtastic_Config_LoRaConfig_RegionCode appliedRegion(size_t index) const { return appliedRegions[index]; } + + bool reconfigure() override + { + receptionWasHeldDuringApply = configApplyReceptionHeld.load(); + const size_t index = reconfigureCount < 2 ? reconfigureCount : 1; + appliedRegions[index] = getActiveLoRaConfig().region; + ++reconfigureCount; + if (reenterDuringApply) { + reenterDuringApply = false; + serviceConfigApply(millis()); + } + const bool result = reconfigureResults[index]; + if (result) + startReceive(); + if (!result && shouldRecordErrors && shouldRecordReconfigureFailure()) + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + return result; + } + + void startReceive() override { ++startReceiveCalls; } + + uint32_t getPacketTime(uint32_t, bool) override { return 0; } + + protected: + void disableInterrupt() override {} + void enableInterrupt(void (*)()) override {} + int16_t getCurrentRSSI() override { return -120; } + bool isChannelActive() override + { + if (requestDuringChannelCheck != nullptr) { + RadioConfigApplyRequest *request = requestDuringChannelCheck; + requestDuringChannelCheck = nullptr; + requestDuringChannelCheckAccepted = requestConfigApply(request); + } + return false; + } + bool isActivelyReceiving() override { return activelyReceiving; } + void addReceiveMetadata(meshtastic_MeshPacket *) override {} + + bool startSend(meshtastic_MeshPacket *packet) override + { + ++startSendCalls; + if (useBaseStartSend) + return RadioLibInterface::startSend(packet); + return true; + } + + private: + meshtastic_MeshPacket inFlightPacket = meshtastic_MeshPacket_init_zero; + bool activelyReceiving = false; + bool reconfigureResults[2] = {true, true}; + meshtastic_Config_LoRaConfig_RegionCode appliedRegions[2] = {}; + size_t reconfigureCount = 0; + RadioConfigApplyRequest *requestDuringChannelCheck = nullptr; + bool requestDuringChannelCheckAccepted = false; + bool useBaseStartSend = false; + bool reenterDuringApply = false; + bool shouldRecordErrors = false; + bool receptionWasHeldDuringApply = false; + uint32_t startSendCalls = 0; + uint32_t startReceiveCalls = 0; +}; + +class TestableLR1121Interface : public LR11x0Interface +{ + public: + TestableLR1121Interface() : LR11x0Interface(nullptr, 0, 0, 0, 0) {} + + using LR11x0Interface::makeReconfigureParams; + + bool wideLora() override { return true; } +}; + +class TestableLR2021Interface : public LR20x0Interface +{ + public: + TestableLR2021Interface() : LR20x0Interface(nullptr, 0, 0, 0, 0) {} + + bool wideLora() override { return true; } }; static void test_bwCodeToKHz_specialMappings() @@ -129,11 +316,992 @@ static void test_clampConfigLora_validPresetUnchanged() TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset); } +static void test_normalizeConfigLora_unnamedChannelUsesCandidatePreset() +{ + 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; + channels.getByIndex(channels.getPrimaryIndex()).settings.name[0] = '\0'; + + meshtastic_Config_LoRaConfig candidate = config.lora; + candidate.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST; + const RegionInfo *region = getRegion(candidate.region); + const float bandwidth = modemPresetToBwKHz(candidate.modem_preset, region->wideLora); + const float slotWidth = region->profile->spacing + (region->profile->padding * 2) + (bandwidth / 1000); + const uint32_t slotCount = round((region->freqEnd - region->freqStart + region->profile->spacing) / slotWidth); + const char *candidatePreset = + DisplayFormatters::getModemPresetDisplayName(candidate.modem_preset, false, candidate.use_preset); + candidate.channel_num = (hash(candidatePreset) % slotCount) + 1; + + const auto normalization = RadioInterface::normalizeConfigLora(candidate, false); + + TEST_ASSERT_TRUE(normalization.valid); + TEST_ASSERT_TRUE(normalization.usesDefaultFrequencySlot); + TEST_ASSERT_FALSE(normalization.usesCustomChannelName); +} + +static void test_lr1121BandwidthCapabilities_matchRadioLibWideBandModes() +{ + TestableLR1121Interface radio; + + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(203.125f, true)); + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(406.25f, true)); + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(812.5f, true)); + TEST_ASSERT_FALSE(radio.supportsLoRaBandwidth(1625.0f, true)); + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(62.5f, false)); + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(125.0f, false)); + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(250.0f, false)); + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(500.0f, false)); + TEST_ASSERT_FALSE(radio.supportsLoRaBandwidth(31.25f, false)); + TEST_ASSERT_FALSE(radio.supportsLoRaBandwidth(812.5f, false)); +} + +static void test_lr2021BandwidthCapabilities_rejectUnsupportedTurboWidth() +{ + TestableLR2021Interface radio; + + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(31.25f, false)); + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(500.0f, false)); + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(203.125f, true)); + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(812.5f, true)); + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(1000.0f, true)); + TEST_ASSERT_FALSE(radio.supportsLoRaBandwidth(1625.0f, true)); +} + +static void test_sx128xBandwidthCapabilities_matchRadioLibWideBandModes() +{ + SX1280Interface radio(nullptr, 0, 0, 0, 0); + + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(203.125f, true)); + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(406.25f, true)); + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(812.5f, true)); + TEST_ASSERT_TRUE(radio.supportsLoRaBandwidth(1625.0f, true)); + TEST_ASSERT_FALSE(radio.supportsLoRaBandwidth(125.0f, true)); + TEST_ASSERT_FALSE(radio.supportsLoRaBandwidth(125.0f, false)); +} + +static void test_normalizeConfigLora_rejectsUnsupportedRadioBandwidth() +{ + TestableLR1121Interface radio; + meshtastic_Config_LoRaConfig candidate = meshtastic_Config_LoRaConfig_init_zero; + candidate.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + candidate.use_preset = false; + candidate.bandwidth = 125; + candidate.spread_factor = 7; + candidate.coding_rate = 5; + + const auto normalization = RadioInterface::normalizeConfigLora(candidate, false, nullptr, &radio); + + TEST_ASSERT_FALSE(normalization.valid); + TEST_ASSERT_EQUAL_UINT8(1, normalization.diagnosticCount); + TEST_ASSERT_EQUAL(RadioInterface::LoRaConfigDiagnosticType::UNSUPPORTED_BANDWIDTH, normalization.diagnostics[0].type); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 125.0f, normalization.diagnostics[0].requestedBandwidthKHz); +} + +static void test_normalizeConfigLora_repairsUnsupportedRadioBandwidth() +{ + TestableLR1121Interface radio; + meshtastic_Config_LoRaConfig candidate = meshtastic_Config_LoRaConfig_init_zero; + candidate.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + candidate.use_preset = false; + candidate.bandwidth = 125; + candidate.spread_factor = 7; + candidate.coding_rate = 5; + + const auto normalization = RadioInterface::normalizeConfigLora(candidate, true, nullptr, &radio); + + TEST_ASSERT_TRUE(normalization.valid); + TEST_ASSERT_EQUAL_UINT16(800, normalization.config.bandwidth); + TEST_ASSERT_EQUAL(RadioInterface::LoRaConfigDiagnosticType::UNSUPPORTED_BANDWIDTH, normalization.diagnostics[0].type); + TEST_ASSERT_TRUE(normalization.diagnostics[0].corrected); +} + +static void test_normalizeConfigLora_rejectsUnsupportedLr1121TurboPreset() +{ + TestableLR1121Interface radio; + meshtastic_Config_LoRaConfig candidate = meshtastic_Config_LoRaConfig_init_zero; + candidate.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + candidate.use_preset = true; + candidate.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + + const auto rejected = RadioInterface::normalizeConfigLora(candidate, false, nullptr, &radio); + const auto repaired = RadioInterface::normalizeConfigLora(candidate, true, nullptr, &radio); + + TEST_ASSERT_FALSE(rejected.valid); + TEST_ASSERT_EQUAL(RadioInterface::LoRaConfigDiagnosticType::UNSUPPORTED_BANDWIDTH, rejected.diagnostics[0].type); + TEST_ASSERT_TRUE(repaired.valid); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, repaired.config.modem_preset); +} + +static void test_applyModemConfig_repairsPersistedUnsupportedBandwidthBeforeDriverUse() +{ + TestableRadioInterface radio; + radio.emulateLr1121Bandwidths(); + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + config.lora.use_preset = false; + config.lora.bandwidth = 125; + config.lora.spread_factor = 7; + config.lora.coding_rate = 5; + + TEST_ASSERT_TRUE(radio.init()); + + TEST_ASSERT_EQUAL_UINT16(125, config.lora.bandwidth); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 812.5f, radio.getBw()); + + NodeDB *savedNodeDB = nodeDB; + const meshtastic_DeviceState savedDeviceState = devicestate; + nodeDB = new NodeDB(); + radio.commitBootConfigCorrection(); + TEST_ASSERT_EQUAL_UINT16(800, config.lora.bandwidth); + delete nodeDB; + nodeDB = savedNodeDB; + devicestate = savedDeviceState; +} + +static void test_configApply_revalidatesCandidateAgainstAcceptingRadio() +{ + TestableRadioInterface radio; + radio.emulateLr1121Bandwidths(); + meshtastic_Config_LoRaConfig previous = meshtastic_Config_LoRaConfig_init_zero; + previous.region = meshtastic_Config_LoRaConfig_RegionCode_US; + meshtastic_Config_LoRaConfig candidate = meshtastic_Config_LoRaConfig_init_zero; + candidate.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + candidate.use_preset = false; + candidate.bandwidth = 125; + candidate.spread_factor = 7; + candidate.coding_rate = 5; + RadioConfigApplyRequest request{previous, candidate, static_cast(millis()), 5000}; + + TEST_ASSERT_FALSE(radio.requestConfigApply(&request)); + TEST_ASSERT_EQUAL(RadioConfigApplyResult::IDLE, request.result.load()); +} + +static void test_configApply_rejectsUnsupportedBandOnAcceptingRadio() +{ + TestableRadioInterface radio; + radio.emulateSubGhzOnly(); + meshtastic_Config_LoRaConfig previous = meshtastic_Config_LoRaConfig_init_zero; + previous.region = meshtastic_Config_LoRaConfig_RegionCode_US; + meshtastic_Config_LoRaConfig candidate = meshtastic_Config_LoRaConfig_init_zero; + candidate.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + candidate.use_preset = true; + candidate.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + RadioConfigApplyRequest request{previous, candidate, static_cast(millis()), 5000}; + + TEST_ASSERT_FALSE(radio.requestConfigApply(&request)); + TEST_ASSERT_EQUAL(RadioConfigApplyResult::IDLE, request.result.load()); +} + +static void test_configApply_wideOnlyRadioAcceptsUnsetWithPrivateHardwareProfile() +{ + TestableRadioInterface radio; + radio.emulateWideOnly(); + meshtastic_Config_LoRaConfig previous = meshtastic_Config_LoRaConfig_init_zero; + previous.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + meshtastic_Config_LoRaConfig candidate = meshtastic_Config_LoRaConfig_init_zero; + candidate.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + candidate.use_preset = true; + candidate.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + RadioConfigApplyRequest request{previous, candidate, static_cast(millis()), 5000}; + + TEST_ASSERT_TRUE(radio.requestConfigApply(&request)); + radio.serviceConfigApply(millis()); + TEST_ASSERT_EQUAL(RadioConfigApplyResult::APPLIED, request.result.load()); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, request.candidate.region); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 812.5f, radio.getBw()); + TEST_ASSERT_TRUE(radio.finalizeConfigApply(&request)); +} + +static void test_bootConfig_wideOnlyRadioStagesLora24RecoveryUntilSelected() +{ + TestableRadioInterface radio; + radio.emulateWideOnly(); + 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; + + TEST_ASSERT_TRUE(radio.init()); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 812.5f, radio.getBw()); + + NodeDB *savedNodeDB = nodeDB; + const meshtastic_DeviceState savedDeviceState = devicestate; + nodeDB = new NodeDB(); + radio.commitBootConfigCorrection(); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_LORA_24, config.lora.region); + delete nodeDB; + nodeDB = savedNodeDB; + devicestate = savedDeviceState; +} + +static void test_bootConfig_subGhzOnlyRadioRejectsLora24WithoutApplying() +{ + TestableRadioInterface radio; + radio.emulateSubGhzOnly(); + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + TEST_ASSERT_FALSE(radio.init()); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_LORA_24, config.lora.region); +} + +static void test_bootConfig_wideOnlyRadioPreservesUnsetRegion() +{ + TestableRadioInterface radio; + radio.emulateWideOnly(); + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + TEST_ASSERT_TRUE(radio.init()); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 812.5f, radio.getBw()); + radio.commitBootConfigCorrection(); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); +} + +static void test_configChanged_wideOnlyRadioUsesPrivateUnsetProfile() +{ + TestableRadioInterface radio; + radio.emulateWideOnly(); + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + TEST_ASSERT_TRUE(radio.init()); + + service->configChanged.notifyObservers(nullptr); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, config.lora.region); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 812.5f, radio.getBw()); +} + // ----------------------------------------------------------------------- // applyModemConfig() coding rate tests (via reconfigure) // ----------------------------------------------------------------------- static TestableRadioInterface *testRadio; +static TestableRadioLibInterface *testRadioLib; + +static meshtastic_Config_LoRaConfig makeLoraConfig(meshtastic_Config_LoRaConfig_RegionCode region) +{ + meshtastic_Config_LoRaConfig loraConfig = meshtastic_Config_LoRaConfig_init_zero; + loraConfig.region = region; + return loraConfig; +} + +static meshtastic_Config_LoRaConfig usConfig() +{ + return makeLoraConfig(meshtastic_Config_LoRaConfig_RegionCode_US); +} + +static meshtastic_Config_LoRaConfig lora24Config() +{ + return makeLoraConfig(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); +} + +class FakeLR11x0Ops +{ + public: + void failAt(LR11x0ApplyStep step, int error) + { + failedStep = step; + failure = error; + } + + bool calledAfter(LR11x0ApplyStep step) const + { + for (size_t i = static_cast(step) + 1; i < sizeof(called) / sizeof(called[0]); i++) + if (called[i]) + return true; + return false; + } + + int standby() { return call(LR11x0ApplyStep::STANDBY); } + int setSpreadingFactor(uint8_t) { return call(LR11x0ApplyStep::SPREADING_FACTOR); } + int setBandwidth(float bandwidth, bool wideBand) + { + receivedBandwidth = bandwidth; + receivedWideBand = wideBand; + return call(LR11x0ApplyStep::BANDWIDTH); + } + int setCodingRate(uint8_t, bool) { return call(LR11x0ApplyStep::CODING_RATE); } + int setSyncWord(uint8_t) { return call(LR11x0ApplyStep::SYNC_WORD); } + int setPreambleLength(uint16_t) { return call(LR11x0ApplyStep::PREAMBLE); } + int setFrequency(float) { return call(LR11x0ApplyStep::FREQUENCY); } + int setOutputPower(int8_t outputPower) + { + receivedOutputPower = outputPower; + return call(LR11x0ApplyStep::OUTPUT_POWER); + } + int setRxBoostedGainMode(bool) { return call(LR11x0ApplyStep::RX_GAIN); } + int startReceive() { return call(LR11x0ApplyStep::START_RECEIVE); } + + private: + int call(LR11x0ApplyStep step) + { + called[static_cast(step)] = true; + return step == failedStep ? failure : RADIOLIB_ERR_NONE; + } + + LR11x0ApplyStep failedStep = LR11x0ApplyStep::COUNT; + int failure = RADIOLIB_ERR_NONE; + bool called[static_cast(LR11x0ApplyStep::COUNT)] = {}; + + public: + float receivedBandwidth = 0; + bool receivedWideBand = false; + int8_t receivedOutputPower = 0; +}; + +class FakeLR11x0BandOps +{ + public: + int setFrequency(float frequency, bool skipCalibration) + { + calls[callCount++] = skipCalibration ? 'S' : 'F'; + receivedFrequency = frequency; + return frequencyResults[frequencyCallCount++]; + } + + int calibrateImage(float frequencyMin, float frequencyMax) + { + calls[callCount++] = 'C'; + receivedCalibrationMin = frequencyMin; + receivedCalibrationMax = frequencyMax; + return calibrationResult; + } + + void waitForFrequencyRetry() { frequencyRetryWaited = true; } + bool isRetryableFrequencyError(int error) { return error == RADIOLIB_ERR_SPI_CMD_FAILED; } + + char calls[4] = {}; + uint8_t callCount = 0; + float receivedFrequency = 0; + float receivedCalibrationMin = 0; + float receivedCalibrationMax = 0; + int frequencyResults[2] = {RADIOLIB_ERR_NONE, RADIOLIB_ERR_NONE}; + uint8_t frequencyCallCount = 0; + bool frequencyRetryWaited = false; + int calibrationResult = RADIOLIB_ERR_NONE; +}; + +class FakeLR11x0BeginOps +{ + public: + int beginLoRa(float bandwidth, uint8_t spreadingFactor, uint8_t codingRate, uint8_t syncWord, uint16_t preambleLength, + bool wideBand) + { + receivedBandwidth = bandwidth; + receivedSpreadingFactor = spreadingFactor; + receivedCodingRate = codingRate; + receivedSyncWord = syncWord; + receivedPreambleLength = preambleLength; + receivedWideBand = wideBand; + return result; + } + + int result = RADIOLIB_ERR_NONE; + float receivedBandwidth = 0; + uint8_t receivedSpreadingFactor = 0; + uint8_t receivedCodingRate = 0; + uint8_t receivedSyncWord = 0; + uint16_t receivedPreambleLength = 0; + bool receivedWideBand = false; +}; + +static void test_lr11x0BeginForBand_forwardsBaseModemParameters() +{ + FakeLR11x0BeginOps ops; + const LR11x0ConfigApplyParams params = {5, 812.5f, 6, 0x2b, 12, 2441.40625f, 13, true, true}; + + TEST_ASSERT_EQUAL(RADIOLIB_ERR_NONE, lr11x0BeginForBand(ops, params)); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 812.5f, ops.receivedBandwidth); + TEST_ASSERT_EQUAL_UINT8(5, ops.receivedSpreadingFactor); + TEST_ASSERT_EQUAL_UINT8(6, ops.receivedCodingRate); + TEST_ASSERT_EQUAL_HEX8(0x2b, ops.receivedSyncWord); + TEST_ASSERT_EQUAL_UINT16(12, ops.receivedPreambleLength); + TEST_ASSERT_TRUE(ops.receivedWideBand); +} + +static void test_lr11x0BandSwitch_calibratesOnlyAfterEnteringSubGhz() +{ + bool configuredWideBand = true; + FakeLR11x0BandOps ops; + + TEST_ASSERT_EQUAL(RADIOLIB_ERR_NONE, lr11x0SetFrequencyForBand(ops, 906.875f, false, configuredWideBand)); + TEST_ASSERT_EQUAL_STRING("SC", ops.calls); + TEST_ASSERT_FALSE(configuredWideBand); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 902.875f, ops.receivedCalibrationMin); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 910.875f, ops.receivedCalibrationMax); +} + +static void test_lr11x0BandSwitch_skipsImageCalibrationForWideBand() +{ + bool configuredWideBand = false; + FakeLR11x0BandOps ops; + + TEST_ASSERT_EQUAL(RADIOLIB_ERR_NONE, lr11x0SetFrequencyForBand(ops, 2441.40625f, true, configuredWideBand)); + TEST_ASSERT_EQUAL_STRING("S", ops.calls); + TEST_ASSERT_TRUE(configuredWideBand); +} + +static void test_lr11x0BandSwitch_sameSubGhzUsesRadioLibCalibration() +{ + bool configuredWideBand = false; + FakeLR11x0BandOps ops; + + TEST_ASSERT_EQUAL(RADIOLIB_ERR_NONE, lr11x0SetFrequencyForBand(ops, 906.875f, false, configuredWideBand)); + TEST_ASSERT_EQUAL_STRING("F", ops.calls); + TEST_ASSERT_FALSE(configuredWideBand); +} + +static void test_lr11x0BandSwitch_retriesTransientSpiFailure() +{ + bool configuredWideBand = true; + FakeLR11x0BandOps ops; + ops.frequencyResults[0] = RADIOLIB_ERR_SPI_CMD_FAILED; + + TEST_ASSERT_EQUAL(RADIOLIB_ERR_NONE, lr11x0SetFrequencyForBand(ops, 906.875f, false, configuredWideBand)); + TEST_ASSERT_EQUAL_STRING("SSC", ops.calls); + TEST_ASSERT_TRUE(ops.frequencyRetryWaited); + TEST_ASSERT_FALSE(configuredWideBand); +} + +static void test_lr11x0Apply_returnsFirstOperationFailure() +{ + static const LR11x0ApplyStep steps[] = { + LR11x0ApplyStep::STANDBY, LR11x0ApplyStep::SPREADING_FACTOR, LR11x0ApplyStep::BANDWIDTH, LR11x0ApplyStep::CODING_RATE, + LR11x0ApplyStep::SYNC_WORD, LR11x0ApplyStep::PREAMBLE, LR11x0ApplyStep::FREQUENCY, LR11x0ApplyStep::OUTPUT_POWER, + LR11x0ApplyStep::RX_GAIN, LR11x0ApplyStep::START_RECEIVE, + }; + const LR11x0ConfigApplyParams params = {11, 250.0f, 5, 0x12, 16, 906.875f, 22, false, false}; + + for (const auto step : steps) { + FakeLR11x0Ops ops; + ops.failAt(step, RADIOLIB_ERR_INVALID_FREQUENCY); + + TEST_ASSERT_EQUAL(RADIOLIB_ERR_INVALID_FREQUENCY, LR11x0ConfigApply::run(ops, params)); + TEST_ASSERT_FALSE(ops.calledAfter(step)); + } +} + +static void test_lr11x0Apply_usesProductionParameters() +{ + TestableLR1121Interface testLR1121; + + config.lora = lora24Config(); + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + config.lora.tx_power = 30; + + FakeLR11x0Ops lora24Ops; + TEST_ASSERT_EQUAL(RADIOLIB_ERR_NONE, LR11x0ConfigApply::run(lora24Ops, testLR1121.makeReconfigureParams())); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 812.5f, lora24Ops.receivedBandwidth); + TEST_ASSERT_TRUE(lora24Ops.receivedWideBand); + TEST_ASSERT_EQUAL_INT8(10, lora24Ops.receivedOutputPower); + + config.lora = usConfig(); + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + config.lora.tx_power = 30; + + FakeLR11x0Ops usOps; + TEST_ASSERT_EQUAL(RADIOLIB_ERR_NONE, LR11x0ConfigApply::run(usOps, testLR1121.makeReconfigureParams())); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 250.0f, usOps.receivedBandwidth); + TEST_ASSERT_FALSE(usOps.receivedWideBand); + TEST_ASSERT_EQUAL_INT8(22, usOps.receivedOutputPower); +} + +static void test_configApply_idle_success_keepsCandidatePrivate() +{ + auto oldConfig = usConfig(); + auto candidate = lora24Config(); + RadioConfigApplyRequest request{oldConfig, candidate, 1000, 5000}; + config.lora = oldConfig; + initRegion(); + RadioInterface::uses_default_frequency_slot = false; + RadioInterface::uses_custom_channel_name = false; + + testRadio->scriptApply(true, true); + TEST_ASSERT_TRUE(testRadio->requestConfigApply(&request)); + testRadio->serviceConfigApply(1000); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::APPLIED, request.result.load()); + TEST_ASSERT_EQUAL(oldConfig.region, config.lora.region); + TEST_ASSERT_EQUAL(oldConfig.region, myRegion->code); + TEST_ASSERT_FALSE(RadioInterface::uses_default_frequency_slot); + TEST_ASSERT_FALSE(RadioInterface::uses_custom_channel_name); + TEST_ASSERT_TRUE(testRadio->getFreq() > 2000.0f); +} + +static void test_configApply_candidateLicensedStateControlsPowerLimit() +{ + auto candidate = makeLoraConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + candidate.use_preset = true; + candidate.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + candidate.tx_power = 30; + config.lora = candidate; + devicestate.owner.is_licensed = false; + + RadioConfigApplyRequest licensedRequest{candidate, candidate, 1000, 5000}; + licensedRequest.candidateLicensed = true; + TEST_ASSERT_TRUE(testRadio->requestConfigApply(&licensedRequest)); + testRadio->serviceConfigApply(1000); + TEST_ASSERT_EQUAL_INT8(30, testRadio->getPower()); + TEST_ASSERT_TRUE(testRadio->finalizeConfigApply(&licensedRequest)); + + RadioConfigApplyRequest unlicensedRequest{candidate, candidate, 1000, 5000}; + TEST_ASSERT_TRUE(testRadio->requestConfigApply(&unlicensedRequest)); + testRadio->serviceConfigApply(1000); + TEST_ASSERT_EQUAL_INT8(getRegion(candidate.region)->powerLimit, testRadio->getPower()); +} + +static void test_configApply_applyFailure_rollsBack() +{ + auto oldConfig = usConfig(); + auto candidate = lora24Config(); + RadioConfigApplyRequest request{oldConfig, candidate, 1000, 5000}; + config.lora = oldConfig; + initRegion(); + RadioInterface::uses_default_frequency_slot = false; + RadioInterface::uses_custom_channel_name = false; + + testRadio->scriptApply(false, true); + TEST_ASSERT_TRUE(testRadio->requestConfigApply(&request)); + testRadio->serviceConfigApply(1000); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK, request.result.load()); + TEST_ASSERT_EQUAL(oldConfig.region, config.lora.region); + TEST_ASSERT_EQUAL(oldConfig.region, myRegion->code); + TEST_ASSERT_FALSE(RadioInterface::uses_default_frequency_slot); + TEST_ASSERT_FALSE(RadioInterface::uses_custom_channel_name); +} + +static void initializeChannelApplyRequest(RadioConfigApplyRequest &request, const char *candidateName) +{ + channelFile = meshtastic_ChannelFile_init_zero; + channels.initDefaults(); + channels.onConfigChanged(); + const meshtastic_ChannelFile previous = channelFile; + meshtastic_ChannelFile candidate = previous; + meshtastic_Channel primary = candidate.channels[0]; + strncpy(primary.settings.name, candidateName, sizeof(primary.settings.name) - 1); + const uint8_t candidatePrimary = Channels::setChannelInFile(candidate, primary, 0); + + request.previous = usConfig(); + request.candidate = usConfig(); + request.requestedAtMsec = 1000; + request.timeoutMsec = 5000; + request.hasPrimarySnapshots = true; + request.previousPrimary = previous.channels[0].settings; + request.candidatePrimary = candidate.channels[candidatePrimary].settings; +} + +static void test_configApply_primarySnapshotNeverPublishesSharedChannels() +{ + RadioConfigApplyRequest request; + initializeChannelApplyRequest(request, "Candidate"); + TEST_ASSERT_EQUAL_STRING("", channels.getPrimary().name); + testRadio->scriptApply(true, true); + + TEST_ASSERT_TRUE(testRadio->requestConfigApply(&request)); + TEST_ASSERT_EQUAL_STRING("", channels.getPrimary().name); + testRadio->serviceConfigApply(1000); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::APPLIED, request.result.load()); + TEST_ASSERT_EQUAL_STRING("", channels.getPrimary().name); +} + +static void test_configApply_primarySnapshotLeavesSharedChannelsOnApplyFailure() +{ + RadioConfigApplyRequest request; + initializeChannelApplyRequest(request, "Candidate"); + testRadio->scriptApply(false, true); + + TEST_ASSERT_TRUE(testRadio->requestConfigApply(&request)); + testRadio->serviceConfigApply(1000); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK, request.result.load()); + TEST_ASSERT_EQUAL_STRING("", channels.getPrimary().name); +} + +static void test_configApply_rollbackFailure_inhibitsTx() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), 1000, 5000}; + config.lora = request.previous; + initRegion(); + RadioInterface::uses_default_frequency_slot = false; + RadioInterface::uses_custom_channel_name = false; + testRadio->scriptApply(false, false); + + TEST_ASSERT_TRUE(testRadio->requestConfigApply(&request)); + testRadio->serviceConfigApply(1000); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::ROLLBACK_FAILED, request.result.load()); + TEST_ASSERT_TRUE(testRadio->configApplyTxInhibited()); + TEST_ASSERT_EQUAL(request.previous.region, config.lora.region); + TEST_ASSERT_EQUAL(request.previous.region, myRegion->code); + TEST_ASSERT_FALSE(RadioInterface::uses_default_frequency_slot); + TEST_ASSERT_FALSE(RadioInterface::uses_custom_channel_name); +} + +static void test_configApply_secondRequest_rejectedBusy() +{ + RadioConfigApplyRequest first{usConfig(), lora24Config(), 1000, 5000}; + RadioConfigApplyRequest second{usConfig(), lora24Config(), 1000, 5000}; + + TEST_ASSERT_TRUE(testRadio->requestConfigApply(&first)); + TEST_ASSERT_FALSE(testRadio->requestConfigApply(&second)); + TEST_ASSERT_EQUAL(RadioConfigApplyResult::BUSY, second.result.load()); +} + +static void test_configApply_activeTx_waits_withoutCompletingPacket() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + config.lora = request.previous; + testRadioLib->setSendingForTest(true); + + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->serviceConfigApply(millis()); + + const auto resultWhileSending = request.result.load(); + const bool stillSending = testRadioLib->sendingForTest(); + const uint32_t appliesWhileSending = testRadioLib->applyCount(); + + testRadioLib->finishTxForTest(); + testRadioLib->serviceConfigApply(millis()); + testRadioLib->clearPendingNotificationForTest(); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::PENDING, resultWhileSending); + TEST_ASSERT_TRUE(stillSending); + TEST_ASSERT_EQUAL_UINT32(0, appliesWhileSending); + TEST_ASSERT_EQUAL(RadioConfigApplyResult::APPLIED, request.result.load()); + TEST_ASSERT_EQUAL(request.previous.region, config.lora.region); + TEST_ASSERT_EQUAL(request.candidate.region, testRadioLib->appliedRegion(0)); +} + +static void test_configApply_barrier_keepsQueuedPacketQueued() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + config.lora = request.previous; + config.lora.tx_enabled = true; + + TEST_ASSERT_EQUAL(ERRNO_OK, testRadioLib->enqueuePacketForTest(&packet)); + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + + testRadioLib->fireTransmitDelayForTest(); + const uint32_t queuedPackets = testRadioLib->queuedPacketCount(); + const uint32_t startedPackets = testRadioLib->startSendCount(); + + testRadioLib->serviceConfigApply(millis()); + testRadioLib->fireTransmitDelayForTest(); + + TEST_ASSERT_EQUAL_UINT32(1, queuedPackets); + TEST_ASSERT_EQUAL_UINT32(0, startedPackets); +} + +static void test_configApply_completion_waitsForFinalizationBeforeResumingTraffic() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + config.lora = request.previous; + config.lora.tx_enabled = true; + + TEST_ASSERT_EQUAL(ERRNO_OK, testRadioLib->enqueuePacketForTest(&packet)); + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->serviceConfigApply(millis()); + testRadioLib->fireTransmitDelayForTest(); + + TEST_ASSERT_EQUAL_UINT32(0, testRadioLib->startSendCount()); + + TEST_ASSERT_FALSE(testRadioLib->finalizeConfigApply(&request)); + TEST_ASSERT_TRUE(testRadioLib->configApplyBarrierForTest()); + testRadioLib->clearPendingNotificationForTest(); + TEST_ASSERT_TRUE(testRadioLib->finalizeConfigApply(&request)); + TEST_ASSERT_FALSE(testRadioLib->configApplyBarrierForTest()); + testRadioLib->fireTransmitDelayForTest(); + + TEST_ASSERT_EQUAL_UINT32(1, testRadioLib->startSendCount()); +} + +static void test_configApply_receptionIsValidatedBeforeMainThreadFinalization() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + config.lora = request.previous; + const uint32_t receiveStarts = testRadioLib->startReceiveCount(); + + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->serviceConfigApply(millis()); + TEST_ASSERT_EQUAL(RadioConfigApplyResult::APPLIED, request.result.load()); + TEST_ASSERT_EQUAL_UINT32(receiveStarts + 1, testRadioLib->startReceiveCount()); + + testRadioLib->serviceConfigApply(millis()); + TEST_ASSERT_EQUAL_UINT32(receiveStarts + 1, testRadioLib->startReceiveCount()); + + TEST_ASSERT_FALSE(testRadioLib->finalizeConfigApply(&request)); + TEST_ASSERT_TRUE(testRadioLib->configApplyReceptionHeldForTest()); + testRadioLib->clearPendingNotificationForTest(); + TEST_ASSERT_TRUE(testRadioLib->finalizeConfigApply(&request)); + TEST_ASSERT_EQUAL_UINT32(receiveStarts + 1, testRadioLib->startReceiveCount()); +} + +static void test_configApply_holdsReceptionBeforeReconfigure() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + config.lora = request.previous; + + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->serviceConfigApply(millis()); + + TEST_ASSERT_TRUE(testRadioLib->receptionWasHeldDuringApplyForTest()); + TEST_ASSERT_TRUE(testRadioLib->configApplyReceptionHeldForTest()); +} + +static void test_configApply_reentrantServiceAppliesExactlyOnce() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + config.lora = request.previous; + testRadioLib->scriptApply(true, true); + testRadioLib->reenterDuringNextApply(); + + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->serviceConfigApply(millis()); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::APPLIED, request.result.load()); + TEST_ASSERT_EQUAL_UINT32(1, testRadioLib->applyCount()); + TEST_ASSERT_FALSE(testRadioLib->finalizeConfigApply(&request)); + testRadioLib->clearPendingNotificationForTest(); + TEST_ASSERT_TRUE(testRadioLib->finalizeConfigApply(&request)); +} + +static void test_configApply_finalizeRejectsReplacementRadio() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + config.lora = request.previous; + + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->serviceConfigApply(millis()); + + TestableRadioLibInterface replacement; + TEST_ASSERT_FALSE(replacement.finalizeConfigApply(&request)); + TEST_ASSERT_FALSE(testRadioLib->finalizeConfigApply(&request)); + testRadioLib->clearPendingNotificationForTest(); + TEST_ASSERT_TRUE(testRadioLib->finalizeConfigApply(&request)); +} + +static void test_configApply_successfulRollbackRestoresCandidateCriticalError() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + config.lora = request.previous; + error_code = meshtastic_CriticalErrorCode_NONE; + error_address = 0; + testRadioLib->scriptApply(false, true); + testRadioLib->recordErrorsOnFailure(); + + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->serviceConfigApply(millis()); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK, request.result.load()); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_NONE, error_code); + TEST_ASSERT_EQUAL_UINT32(0, error_address); +} + +static void test_reconfigureFailureOutsideTransactionRecordsCriticalError() +{ + error_code = meshtastic_CriticalErrorCode_NONE; + error_address = 0; + testRadioLib->scriptApply(false, true); + testRadioLib->recordErrorsOnFailure(); + + TEST_ASSERT_FALSE(testRadioLib->reconfigure()); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING, error_code); + TEST_ASSERT_NOT_EQUAL(0, error_address); +} + +static void test_reconfigureCommittedFailureRecordsCriticalError() +{ + error_code = meshtastic_CriticalErrorCode_NONE; + error_address = 0; + config.lora = usConfig(); + testRadioLib->scriptApply(false, true); + testRadioLib->recordErrorsOnFailure(); + + TEST_ASSERT_FALSE(testRadioLib->reconfigureCommitted()); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING, error_code); + TEST_ASSERT_NOT_EQUAL(0, error_address); +} + +static void test_configApply_rollbackFailureKeepsCriticalError() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + config.lora = request.previous; + error_code = meshtastic_CriticalErrorCode_NONE; + error_address = 0; + testRadioLib->scriptApply(false, false); + testRadioLib->recordErrorsOnFailure(); + + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->serviceConfigApply(millis()); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::ROLLBACK_FAILED, request.result.load()); + TEST_ASSERT_EQUAL(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING, error_code); + TEST_ASSERT_NOT_EQUAL(0, error_address); +} + +static void test_configApply_timeout_doesNotAbortTx() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis() - 1), 0}; + config.lora = request.previous; + testRadioLib->setSendingForTest(true); + + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->serviceConfigApply(millis()); + + const auto result = request.result.load(); + const bool stillSending = testRadioLib->sendingForTest(); + testRadioLib->finishTxForTest(); + testRadioLib->clearPendingNotificationForTest(); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::TIMED_OUT, result); + TEST_ASSERT_TRUE(stillSending); +} + +static void test_configApply_timeoutIsRolloverSafe() +{ + const uint32_t requestedAtMsec = UINT32_MAX - 10; + const uint32_t timeoutMsec = 20; + RadioConfigApplyRequest request{usConfig(), lora24Config(), requestedAtMsec, timeoutMsec}; + config.lora = request.previous; + testRadioLib->setSendingForTest(true); + + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->serviceConfigApply(5); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::PENDING, request.result.load()); + + testRadioLib->serviceConfigApply(9); + TEST_ASSERT_EQUAL(RadioConfigApplyResult::TIMED_OUT, request.result.load()); + TEST_ASSERT_FALSE(testRadioLib->finalizeConfigApply(&request)); + testRadioLib->finishTxForTest(); + testRadioLib->clearPendingNotificationForTest(); + TEST_ASSERT_TRUE(testRadioLib->finalizeConfigApply(&request)); +} + +static void test_configApply_activeRx_defersUntilReceiveCompletes() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + config.lora = request.previous; + testRadioLib->setActivelyReceivingForTest(true); + + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->serviceConfigApply(millis()); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::PENDING, request.result.load()); + TEST_ASSERT_EQUAL_UINT32(0, testRadioLib->applyCount()); + + testRadioLib->setActivelyReceivingForTest(false); + testRadioLib->serviceConfigApply(millis()); + testRadioLib->clearPendingNotificationForTest(); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::APPLIED, request.result.load()); +} + +static void test_configApply_activeRx_retriesAfterNotificationIsConsumed() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + config.lora = request.previous; + testRadioLib->setActivelyReceivingForTest(true); + + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->clearPendingNotificationForTest(); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::PENDING, request.result.load()); + TEST_ASSERT_EQUAL_UINT32(0, testRadioLib->applyCount()); + + testRadioLib->setActivelyReceivingForTest(false); + testRadioLib->clearPendingNotificationForTest(); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::APPLIED, request.result.load()); +} + +static void test_configApply_applyFailure_rollsBackRadioLib() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + config.lora = request.previous; + testRadioLib->scriptApply(false, true); + + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->serviceConfigApply(millis()); + testRadioLib->clearPendingNotificationForTest(); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::APPLY_FAILED_ROLLED_BACK, request.result.load()); + TEST_ASSERT_EQUAL(request.previous.region, config.lora.region); + TEST_ASSERT_EQUAL(request.candidate.region, testRadioLib->appliedRegion(0)); + TEST_ASSERT_EQUAL(request.previous.region, testRadioLib->appliedRegion(1)); + TEST_ASSERT_FALSE(testRadioLib->configApplyTxInhibited()); +} + +static void test_configApply_rollbackFailure_inhibitsRadioLibTx() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + config.lora = request.previous; + testRadioLib->scriptApply(false, false); + + TEST_ASSERT_TRUE(testRadioLib->requestConfigApply(&request)); + testRadioLib->serviceConfigApply(millis()); + testRadioLib->clearPendingNotificationForTest(); + + TEST_ASSERT_EQUAL(RadioConfigApplyResult::ROLLBACK_FAILED, request.result.load()); + TEST_ASSERT_TRUE(testRadioLib->configApplyTxInhibited()); + TEST_ASSERT_TRUE(testRadioLib->configApplyReceptionHeldForTest()); +} + +static void test_configApply_barrierBlocksDequeuesAcceptedDuringChannelCheck() +{ + RadioConfigApplyRequest request{usConfig(), lora24Config(), static_cast(millis()), 5000}; + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + config.lora = request.previous; + config.lora.tx_enabled = true; + + TEST_ASSERT_EQUAL(ERRNO_OK, testRadioLib->enqueuePacketForTest(&packet)); + testRadioLib->requestConfigApplyDuringNextChannelCheck(&request); + testRadioLib->fireTransmitDelayForTest(); + + TEST_ASSERT_TRUE(testRadioLib->requestDuringChannelCheckWasAccepted()); + TEST_ASSERT_EQUAL_UINT32(1, testRadioLib->queuedPacketCount()); + TEST_ASSERT_EQUAL_UINT32(0, testRadioLib->startSendCount()); + + testRadioLib->serviceConfigApply(millis()); + testRadioLib->fireTransmitDelayForTest(); +} + +static void test_configApply_txInhibitContinuesDrainingQueue() +{ + config.lora.tx_enabled = true; + testRadioLib->useBaseStartSendForTest(true); + + meshtastic_MeshPacket *first = packetPool.allocZeroed(); + meshtastic_MeshPacket *second = packetPool.allocZeroed(); + TEST_ASSERT_NOT_NULL(first); + TEST_ASSERT_NOT_NULL(second); + TEST_ASSERT_EQUAL(ERRNO_OK, testRadioLib->enqueuePacketForTest(first)); + TEST_ASSERT_EQUAL(ERRNO_OK, testRadioLib->enqueuePacketForTest(second)); + testRadioLib->setConfigApplyTxInhibit(true); + + testRadioLib->fireTransmitDelayForTest(); + testRadioLib->clearPendingNotificationForTest(); + + TEST_ASSERT_EQUAL_UINT32(2, testRadioLib->startSendCount()); + TEST_ASSERT_EQUAL_UINT32(0, testRadioLib->queuedPacketCount()); +} // After fresh flash: coding_rate=0, use_preset=true, modem_preset=LONG_FAST // CR should come from the preset (5 for LONG_FAST), not from the zero default. @@ -341,9 +1509,20 @@ void setUp(void) initRegion(); testRadio = new TestableRadioInterface(); + testAirTime = new AirTime(); + airTime = testAirTime; + + testRadioLib = new TestableRadioLibInterface(); } void tearDown(void) { + delete testRadioLib; + testRadioLib = nullptr; + + airTime = nullptr; + delete testAirTime; + testAirTime = nullptr; + delete testRadio; testRadio = nullptr; service = nullptr; @@ -368,6 +1547,21 @@ void setup() RUN_TEST(test_validateConfigLora_rejectsInvalidPresetForRegion); RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault); RUN_TEST(test_clampConfigLora_validPresetUnchanged); + RUN_TEST(test_normalizeConfigLora_unnamedChannelUsesCandidatePreset); + RUN_TEST(test_lr1121BandwidthCapabilities_matchRadioLibWideBandModes); + RUN_TEST(test_lr2021BandwidthCapabilities_rejectUnsupportedTurboWidth); + RUN_TEST(test_sx128xBandwidthCapabilities_matchRadioLibWideBandModes); + RUN_TEST(test_normalizeConfigLora_rejectsUnsupportedRadioBandwidth); + RUN_TEST(test_normalizeConfigLora_repairsUnsupportedRadioBandwidth); + RUN_TEST(test_normalizeConfigLora_rejectsUnsupportedLr1121TurboPreset); + RUN_TEST(test_applyModemConfig_repairsPersistedUnsupportedBandwidthBeforeDriverUse); + RUN_TEST(test_configApply_revalidatesCandidateAgainstAcceptingRadio); + RUN_TEST(test_configApply_rejectsUnsupportedBandOnAcceptingRadio); + RUN_TEST(test_configApply_wideOnlyRadioAcceptsUnsetWithPrivateHardwareProfile); + RUN_TEST(test_bootConfig_wideOnlyRadioStagesLora24RecoveryUntilSelected); + RUN_TEST(test_bootConfig_subGhzOnlyRadioRejectsLora24WithoutApplying); + RUN_TEST(test_bootConfig_wideOnlyRadioPreservesUnsetRegion); + RUN_TEST(test_configChanged_wideOnlyRadioUsesPrivateUnsetProfile); RUN_TEST(test_applyModemConfig_freshFlashCodingRateNotZero); RUN_TEST(test_applyModemConfig_codingRateMatchesPreset); RUN_TEST(test_applyModemConfig_customCodingRateHigherThanPreset); @@ -375,6 +1569,39 @@ void setup() RUN_TEST(test_applyModemConfig_mediumTurbo); RUN_TEST(test_clampConfigLora_mediumTurboInvalidForEU868); RUN_TEST(test_clampConfigLora_mediumTurboValidForUS); + RUN_TEST(test_lr11x0Apply_returnsFirstOperationFailure); + RUN_TEST(test_lr11x0Apply_usesProductionParameters); + RUN_TEST(test_lr11x0BandSwitch_calibratesOnlyAfterEnteringSubGhz); + RUN_TEST(test_lr11x0BandSwitch_skipsImageCalibrationForWideBand); + RUN_TEST(test_lr11x0BandSwitch_sameSubGhzUsesRadioLibCalibration); + RUN_TEST(test_lr11x0BandSwitch_retriesTransientSpiFailure); + RUN_TEST(test_lr11x0BeginForBand_forwardsBaseModemParameters); + RUN_TEST(test_configApply_idle_success_keepsCandidatePrivate); + RUN_TEST(test_configApply_candidateLicensedStateControlsPowerLimit); + RUN_TEST(test_configApply_applyFailure_rollsBack); + RUN_TEST(test_configApply_primarySnapshotNeverPublishesSharedChannels); + RUN_TEST(test_configApply_primarySnapshotLeavesSharedChannelsOnApplyFailure); + RUN_TEST(test_configApply_rollbackFailure_inhibitsTx); + RUN_TEST(test_configApply_secondRequest_rejectedBusy); + RUN_TEST(test_configApply_activeTx_waits_withoutCompletingPacket); + RUN_TEST(test_configApply_barrier_keepsQueuedPacketQueued); + RUN_TEST(test_configApply_completion_waitsForFinalizationBeforeResumingTraffic); + RUN_TEST(test_configApply_receptionIsValidatedBeforeMainThreadFinalization); + RUN_TEST(test_configApply_holdsReceptionBeforeReconfigure); + RUN_TEST(test_configApply_reentrantServiceAppliesExactlyOnce); + RUN_TEST(test_configApply_finalizeRejectsReplacementRadio); + RUN_TEST(test_configApply_successfulRollbackRestoresCandidateCriticalError); + RUN_TEST(test_reconfigureFailureOutsideTransactionRecordsCriticalError); + RUN_TEST(test_reconfigureCommittedFailureRecordsCriticalError); + RUN_TEST(test_configApply_rollbackFailureKeepsCriticalError); + RUN_TEST(test_configApply_timeout_doesNotAbortTx); + RUN_TEST(test_configApply_timeoutIsRolloverSafe); + RUN_TEST(test_configApply_activeRx_defersUntilReceiveCompletes); + RUN_TEST(test_configApply_activeRx_retriesAfterNotificationIsConsumed); + RUN_TEST(test_configApply_applyFailure_rollsBackRadioLib); + RUN_TEST(test_configApply_rollbackFailure_inhibitsRadioLibTx); + RUN_TEST(test_configApply_barrierBlocksDequeuesAcceptedDuringChannelCheck); + RUN_TEST(test_configApply_txInhibitContinuesDrainingQueue); RUN_TEST(test_regionPresetMap_coversAllRegionsWithinBounds); RUN_TEST(test_regionPresetMap_matchesRegionTable); exit(UNITY_END());