Skip to content
Closed
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
bc3ec36
feat(radio): add asynchronous config apply contract
RCGV1 Jul 30, 2026
47e7c1f
fix(radio): serialize config changes with transmit state
RCGV1 Jul 30, 2026
930a735
fix(radio): synchronize config apply handoff
RCGV1 Jul 30, 2026
bea56a7
fix(lr1121): report live reconfiguration failures
RCGV1 Jul 30, 2026
5f77b57
test(lr1121): verify production 2.4 GHz parameters
RCGV1 Jul 30, 2026
9c07c7a
test(lr1121): exercise shared band policy
RCGV1 Jul 30, 2026
83f1d80
fix(admin): persist LoRa config after radio apply
RCGV1 Jul 30, 2026
4ead5f6
fix(config): isolate pending LoRa state
RCGV1 Jul 30, 2026
eba8358
fix(admin): reject invalid LoRa transactions early
RCGV1 Jul 30, 2026
c239adb
fix(radio): unify persistent LoRa configuration changes
RCGV1 Jul 30, 2026
cf59c8a
fix(menu): stage licensed radio transitions
RCGV1 Jul 30, 2026
fbecedc
fix(build): avoid ESP32 aggregate reset compiler crash
RCGV1 Jul 30, 2026
3e00fc1
fix(radio): retry deferred configuration apply
RCGV1 Jul 31, 2026
2320eee
fix(lr1121): sequence live cross-band calibration
RCGV1 Jul 31, 2026
c101ba9
fix(radio): propagate live reconfiguration failures
RCGV1 Jul 31, 2026
abc3536
fix(radio): capture driver context in error handlers
RCGV1 Jul 31, 2026
6af90b8
fix(radio): harden live LoRa configuration
RCGV1 Jul 31, 2026
d5eddff
Merge remote-tracking branch 'origin/develop' into codex/24ghz-rough-…
RCGV1 Jul 31, 2026
d8fb536
fix(radio): serialize channel-driven reconfiguration
RCGV1 Jul 31, 2026
8458d2f
fix(build): avoid aggregate reset compiler failure
RCGV1 Jul 31, 2026
2786c56
Merge remote-tracking branch 'origin/develop' into codex/24ghz-rough-…
RCGV1 Jul 31, 2026
6f2822e
fix(radio): address configuration review findings
RCGV1 Jul 31, 2026
1a797a7
fix(radio): initialize staged apply state
RCGV1 Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Power.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
#include "Power.h"
#include "BluetoothCommon.h"
#include "MeshService.h"
#include "MessageStore.h"
#include "NodeDB.h"
#include "PowerFSM.h"
Expand Down Expand Up @@ -828,6 +829,9 @@ bool Power::setup()

void Power::powerCommandsCheck()
{
if (service && service->loRaConfigApplyActive())
return;

if (rebootAtMsec && millis() > rebootAtMsec) {
LOG_INFO("Rebooting");
reboot();
Expand Down
10 changes: 9 additions & 1 deletion src/concurrency/NotifiedWorkerThread.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -93,4 +101,4 @@ int32_t NotifiedWorkerThread::runOnce()
return RUN_SAME;
}

} // namespace concurrency
} // namespace concurrency
3 changes: 3 additions & 0 deletions src/concurrency/NotifiedWorkerThread.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
81 changes: 32 additions & 49 deletions src/graphics/draw/MenuHandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -321,7 +306,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
menuQueue = LicensedToNormalConfirm;
screen->runNow();
} else {
applyLoraRegion(selectedRegion, false);
applyLoraRegion(selectedRegion);
}
});

Expand All @@ -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);
}
Expand All @@ -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);
applyLoraRegion(pendingRegion,
selected == 1 ? AdminModule::MenuLoRaTransition::EXIT_LICENSED : AdminModule::MenuLoRaTransition::NONE);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect banner dismissal/timeout semantics for overlay banners.
set -euo pipefail
fd -t f 'BannerOverlay*' src | xargs -r -I{} sh -c 'echo "== {}"; cat -n "{}"'
rg -n -C6 'bannerCallback' src/graphics --glob '!**/MenuHandler.cpp' | head -100

Repository: meshtastic/firmware

Length of output: 7940


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== MenuHandler.cpp relevant section =="
sed -n '310,365p' src/graphics/draw/MenuHandler.cpp | cat -n -v

echo "== NotificationRenderer banner callback selection logic =="
rg -n -C8 'drawBannercallback|alertBannerCallback|curSelected|optionsEnumPtr|optionsArrayPtr|showOverlayBanner' src/graphics --glob '!**/MenuHandler.cpp' | head -220

echo "== MenuHandler LoraRegionPicker related definitions/usages =="
rg -n -C4 'pendingRegion|LoraRegionPicker|confirmOptions|showOverlayBanner' src/graphics src --glob '!**/NotificationRenderer.cpp' | head -220

Repository: meshtastic/firmware

Length of output: 33128


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== NotificationRenderer.cpp drawBannercallback and selection/dismiss handling =="
fd -t f 'NotificationRenderer.cpp' src | xargs -r -I{} sh -c 'echo "== {}"; rg -n -C8 "void NotificationRenderer::drawBannercallback|alertBannerCallback|curSelected|alertBannerUntil|pauseBanner|current_notification_type|SELECT|UP|DOWN|ENTER" "{}" | head -300'

echo "== Header declarations for banner state/callbacks =="
fd -t h 'NotificationRenderer.h|Screen.h' src | xargs -r -I{} sh -c 'echo "== {}"; rg -n -C4 "alertBannerCallback|curSelected|alertBannerUntil|pauseBanner|optionsCount|durationMs|InitialSelected" "{}"'

Repository: meshtastic/firmware

Length of output: 13647


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== NotificationRenderer.cpp selection_picker option callback section =="
sed -n '260,370p' src/graphics/draw/NotificationRenderer.cpp | cat -n -v

echo "== NotificationRenderer helper: isOverlayBannerShowing/relevant option handling =="
rg -n -C5 'isOverlayBannerShowing|drawSelectionPicker|alertBannerCallback\\(curSelected\\)|resetBanner\\(\\);\\s*$' src/graphics/draw/NotificationRenderer.cpp src/graphics/draw/NotificationRenderer.h

echo "== all selection_callback callers of showOverlayBanner =="
rg -n -C3 'showOverlayBanner\\(.*bannerCallback|optionsEnumPtr|alertBannerCallback\\(curSelected\\)|alertBannerCallback\\(selected\\)|alertBannerCallback\\(' src/graphics src --glob '!**/MenuHandler.cpp'

Repository: meshtastic/firmware

Length of output: 8570


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== all selection_picker callers =="
rg -n -C3 'notificationTypeEnum::selection_picker|InitialSelected|optionsEnumPtr|optionsArrayPtr' src/graphics/src src/graphics/draw src/graphics/draw/MenuHandler.cpp src/graphics/draw/NotificationRenderer.cpp

echo "== NotificationRenderer drawAlertBannerOverlay if option logic exists anywhere =="
rg -n -C8 'drawAlertBannerOverlay|alertBannerCallback|curSelected|resetBanner\\(\\)' src/graphics/draw/NotificationRenderer.cpp src/graphics/draw/NotificationRenderer.h

echo "== static initializer behavior probe in repository headers =="
python3 - <<'PY'
from pathlib import Path
p=Path('src/graphics/Screen.h')
print(p.read_text(errors='ignore').split('struct BannerOverlayOptions')[1].split('}')[0])
PY

Repository: meshtastic/firmware

Length of output: 50375


Keep licensed to normal licensed mode if the prompt times out/dismisses.

licensedToNormalConfirmMenu() has no notificationType = text_banner, so drawAlertBannerOverlay() runs and calls alertBannerCallback(curSelected) on selection. Since LicensedToNormalConfirmMenu lacks an explicit text_banner, the timeout path does not call the callback, but if an option is selected while the banner is focused on Keep licensed, it applies pendingRegion as an unlicensed mode.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/graphics/draw/MenuHandler.cpp` around lines 349 - 351, Update
licensedToNormalConfirmMenu() and its selection handling so a timeout or
dismissal, as well as selecting “Keep licensed” while the alert banner is
focused, preserves the normal licensed mode instead of applying pendingRegion as
an unlicensed mode. Ensure the alert-banner callback path and default transition
both use the licensed transition, while retaining the explicit exit behavior for
the other option.

screen->showOverlayBanner(confirmBanner);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -524,11 +506,12 @@ static BannerOverlayOptions buildRegionPresetBanner()
screen->runNow();
return;
}
config.lora.use_preset = true;
config.lora.modem_preset = static_cast<meshtastic_Config_LoRaConfig_ModemPreset>(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<meshtastic_Config_LoRaConfig_ModemPreset>(selected);
candidate.channel_num = 0; // Reset to default channel for the preset
candidate.override_frequency = 0; // Clear any custom frequency
requestMenuLoRaConfig(candidate);
};
return bannerOptions;
}
Expand Down
56 changes: 19 additions & 37 deletions src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <RadioLibInterface.h>
#include <target_specific.h>
#if defined(ARCH_ESP32) && HAS_WIFI
Expand Down Expand Up @@ -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)
Expand All @@ -370,16 +358,10 @@ 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;
requestMenuLoRaConfig(candidate);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

static void applyConfigReload(uint32_t changes = SEGMENT_CONFIG, bool reboot = false)
Expand Down
6 changes: 5 additions & 1 deletion src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1447,6 +1447,10 @@ void loop()
#endif

service->loop();
#if defined(ARCH_PORTDUINO) && !defined(ARCH_PORTDUINO_WASM) && __has_include(<ulfius.h>)
if (piwebServerThread)
piwebServerThread->processPendingRequests();
#endif
#if !MESHTASTIC_EXCLUDE_INPUTBROKER && defined(HAS_FREE_RTOS) && !defined(ARCH_RP2040)
if (inputBroker)
inputBroker->processInputEventQueue();
Expand All @@ -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;
Expand Down
67 changes: 60 additions & 7 deletions src/mesh/Channels.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading