Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d5bb4e5
reconfigure tests
NomDeTom Jul 23, 2026
7fe5714
Narrow radio-reload trigger to LoRa-affecting saves only
NomDeTom Jul 23, 2026
234e17b
mark up todo sites for checking reloadConfig use
NomDeTom Jul 23, 2026
b95b0f3
added clarifying note
NomDeTom Jul 23, 2026
2223f80
Don't reboot on no-op position/network/bluetooth config sets
NomDeTom Jul 23, 2026
d1a001e
Apply live position config changes without a reboot
NomDeTom Jul 23, 2026
8ff02b6
docs: config-save radio-reload & reboot gating
NomDeTom Jul 23, 2026
4b38c3b
docs: add hardware testing section to config-save gating
NomDeTom Jul 23, 2026
86124cd
docs: clarify hardware tests run over serial, not menus/BLE
NomDeTom Jul 23, 2026
ea5412b
docs: correct crash mechanism to the off-main-thread spiLock lockup
NomDeTom Jul 23, 2026
6f4760f
docs: clarify config save behavior for GPS position updates
NomDeTom Jul 24, 2026
1f6b455
menu actions
NomDeTom Jul 24, 2026
46c35b2
menu actions reboot
NomDeTom Jul 24, 2026
ef59f2c
Persist telemetry screen toggles from the frame menu
NomDeTom Jul 25, 2026
7fe0671
Drop redundant and over-broad config writes from the menus
NomDeTom Jul 26, 2026
01fc824
Route config saves through one MeshService::applyConfigChange helper
NomDeTom Jul 26, 2026
02918c8
Extract menu config actions into testable functions
NomDeTom Jul 26, 2026
2b40f29
Sort out InkHUD applying-changes notifications
NomDeTom Jul 26, 2026
4642c6c
stylee
NomDeTom Jul 26, 2026
d0c00dc
Fix stale doc references and document the menu path
NomDeTom Jul 26, 2026
138b563
Fix smart-broadcast-interval reboot and carry transaction flags
NomDeTom Jul 26, 2026
3e2f393
Stop the edit transaction discarding the per-field save decisions
NomDeTom Jul 26, 2026
3430507
post rebase fixes
NomDeTom Jul 27, 2026
78c9028
gps-toggle-noreboot
NomDeTom Jul 27, 2026
cc42d82
fix(admin): preserve live config transactions
RCGV1 Jul 30, 2026
490e0a8
fix(admin): avoid unnecessary config restarts
RCGV1 Jul 30, 2026
5a589aa
fix(admin): keep edit timeout dormant while idle
RCGV1 Jul 30, 2026
6da1364
fix(admin): skip normalized no-op reboots
RCGV1 Jul 30, 2026
0de76bb
fix(admin): disable idle transaction timer
RCGV1 Jul 30, 2026
59240a1
Merge current tidyups into restart hardening
RCGV1 Jul 30, 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
377 changes: 377 additions & 0 deletions docs/admin-config-save-gating.md

Large diffs are not rendered by default.

119 changes: 65 additions & 54 deletions src/graphics/draw/MenuHandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,8 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool
if (gps != nullptr && !gps->isEnabled() && config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED)
gps->enable();
#endif
service->reloadConfig(changes);
// Region/preset/HAM-mode change - the only LoRa radio parameters this menu can touch.
service->applyConfigChange(changes, CONFIG_APPLY_RADIO);
}

void menuHandler::LoraRegionPicker(uint32_t duration)
Expand Down Expand Up @@ -398,8 +399,7 @@ void menuHandler::deviceRolePicker()
} else if (selected == devicerole_tracker) {
config.device.role = meshtastic_Config_DeviceConfig_Role_TRACKER;
}
service->reloadConfig(SEGMENT_CONFIG);
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_REBOOT);
};
screen->showOverlayBanner(bannerOptions);
}
Expand Down Expand Up @@ -471,7 +471,7 @@ void menuHandler::FrequencySlotPicker()
}

config.lora.channel_num = selected;
service->reloadConfig(SEGMENT_CONFIG);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_RADIO);
};

screen->showOverlayBanner(bannerOptions);
Expand Down Expand Up @@ -527,7 +527,7 @@ static BannerOverlayOptions buildRegionPresetBanner()
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);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_RADIO);
};
return bannerOptions;
}
Expand All @@ -554,7 +554,7 @@ void menuHandler::twelveHourPicker()
} else {
config.display.use_12h_clock = false;
}
service->reloadConfig(SEGMENT_CONFIG);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_NONE);
};
screen->showOverlayBanner(bannerOptions);
}
Expand Down Expand Up @@ -658,7 +658,7 @@ void menuHandler::TZPicker()
config.device.tzdef[sizeof(config.device.tzdef) - 1] = '\0';

setenv("TZ", config.device.tzdef, 1);
service->reloadConfig(SEGMENT_CONFIG);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_NONE);
});

int initialSelection = 0;
Expand Down Expand Up @@ -770,7 +770,7 @@ void menuHandler::messageResponseMenu()
auto &chan = channels.getByIndex(chIndex);
if (chan.settings.has_module_settings) {
chan.settings.module_settings.is_muted = !chan.settings.module_settings.is_muted;
nodeDB->saveToDisk();
nodeDB->saveToDisk(SEGMENT_CHANNELS); // channel setting: don't rewrite every other proto
}

} else if (selected == DeleteMenu) {
Expand Down Expand Up @@ -1599,16 +1599,9 @@ void menuHandler::manageNodeMenu()
}

if (selected == Mute) {
auto n = nodeDB->getMeshNode(menuHandler::pickedNodeNum);
if (!n) {
return;
}

const bool wasMuted = nodeInfoLiteIsMuted(n);
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_MUTED_MASK, !wasMuted);
LOG_INFO(wasMuted ? "Unmuted node %08X" : "Muted node %08X", menuHandler::pickedNodeNum);
nodeDB->notifyObservers(true);
nodeDB->saveToDisk();
// No lookup or null check here: toggleNodeMuted() resolves the node itself and returns
// without writing if it is unknown.
toggleNodeMuted(menuHandler::pickedNodeNum);
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
return;
}
Expand Down Expand Up @@ -1650,7 +1643,7 @@ void menuHandler::manageNodeMenu()
// refusal changed nothing and shouldn't trigger a prefs save.
if (changed) {
nodeDB->notifyObservers(true);
nodeDB->saveToDisk();
nodeDB->saveToDisk(SEGMENT_NODEDATABASE); // NodeInfoLite bit: only the node DB changed
}
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
return;
Expand Down Expand Up @@ -1687,8 +1680,7 @@ void menuHandler::nodeNameLengthMenu()
}

config.display.use_long_node_name = option.value;
saveUIConfig();
service->reloadConfig(SEGMENT_CONFIG);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_NONE);
LOG_INFO("Setting names to %s", option.value ? "long" : "short");
});

Expand All @@ -1713,12 +1705,12 @@ void menuHandler::resetNodeDBMenu()
LOG_INFO("Initiate node-db reset");
nodeDB->resetNodes();
disableBluetooth();
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
requestReboot();
} else if (selected == 2) {
LOG_INFO("Initiate node-db reset but keeping favorites");
nodeDB->resetNodes(1);
disableBluetooth();
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
requestReboot();
} else if (selected == 0) {
menuQueue = NodeBaseMenu;
screen->runNow();
Expand Down Expand Up @@ -1808,7 +1800,7 @@ void menuHandler::GPSToggleMenu()
playGPSDisableBeep();
gps->disable();
}
service->reloadConfig(SEGMENT_CONFIG);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_NONE);
});

int initialSelection = 0;
Expand Down Expand Up @@ -1865,9 +1857,10 @@ void menuHandler::GPSFormatMenu()
return;
}

// uiconfig field: saveUIConfig() writes /prefs/uiconfig.proto, which is the only file
// this touches. No config.proto write, so no reloadConfig().
uiconfig.gps_format = option.value;
saveUIConfig();
service->reloadConfig(SEGMENT_CONFIG);
};

BannerOverlayOptions bannerOptions;
Expand Down Expand Up @@ -1910,15 +1903,9 @@ void menuHandler::GPSSmartPositionMenu()
menuQueue = PositionBaseMenu;
screen->runNow();
} else if (selected == 1) {
config.position.position_broadcast_smart_enabled = true;
saveUIConfig();
service->reloadConfig(SEGMENT_CONFIG);
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
setSmartPositionEnabled(true);
} else if (selected == 2) {
config.position.position_broadcast_smart_enabled = false;
saveUIConfig();
service->reloadConfig(SEGMENT_CONFIG);
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
setSmartPositionEnabled(false);
}
};
bannerOptions.InitialSelected = config.position.position_broadcast_smart_enabled ? 1 : 2;
Expand Down Expand Up @@ -1971,9 +1958,7 @@ void menuHandler::GPSUpdateIntervalMenu()
}

if (selected != 0) {
saveUIConfig();
service->reloadConfig(SEGMENT_CONFIG);
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_REBOOT);
}
};

Expand Down Expand Up @@ -2061,9 +2046,8 @@ void menuHandler::GPSPositionBroadcastMenu()
}

if (selected != 0) {
saveUIConfig();
service->reloadConfig(SEGMENT_CONFIG);
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
// Read live by PositionModule's broadcast scheduler every cycle - no reboot needed.
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_NONE);
}
};

Expand Down Expand Up @@ -2138,7 +2122,7 @@ void menuHandler::BuzzerModeMenu()
bannerOptions.optionsCount = 5;
bannerOptions.bannerCallback = [](int selected) -> void {
config.device.buzzer_mode = (meshtastic_Config_DeviceConfig_BuzzerMode)selected;
service->reloadConfig(SEGMENT_CONFIG);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_NONE);
};
bannerOptions.InitialSelected = config.device.buzzer_mode;
screen->showOverlayBanner(bannerOptions);
Expand Down Expand Up @@ -2203,8 +2187,7 @@ void menuHandler::switchToMUIMenu()
if (selected == 1) {
config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR;
config.bluetooth.enabled = false;
service->reloadConfig(SEGMENT_CONFIG);
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_REBOOT);
}
};
screen->showOverlayBanner(bannerOptions);
Expand All @@ -2225,7 +2208,7 @@ void menuHandler::rebootMenu()
IF_SCREEN(screen->showSimpleBanner("Rebooting...", 0));
nodeDB->saveToDisk();
messageStore.saveToFlash();
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
requestReboot();
} else {
menuQueue = PowerMenu;
screen->runNow();
Expand Down Expand Up @@ -2375,13 +2358,11 @@ void menuHandler::wifiToggleMenu()
if (selected == Wifi_disable) {
config.network.wifi_enabled = false;
config.bluetooth.enabled = true;
service->reloadConfig(SEGMENT_CONFIG);
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_REBOOT);
} else if (selected == Wifi_enable) {
config.network.wifi_enabled = true;
config.bluetooth.enabled = false;
service->reloadConfig(SEGMENT_CONFIG);
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_REBOOT);
}
};
screen->showOverlayBanner(bannerOptions);
Expand Down Expand Up @@ -2656,15 +2637,15 @@ void menuHandler::frameTogglesMenu()
menuHandler::menuQueue = menuHandler::FrameToggles;
screen->runNow();
} else if (selected == show_env_telemetry) {
moduleConfig.telemetry.environment_screen_enabled = !moduleConfig.telemetry.environment_screen_enabled;
toggleTelemetryScreen(moduleConfig.telemetry.environment_screen_enabled);
menuHandler::menuQueue = menuHandler::FrameToggles;
screen->runNow();
} else if (selected == show_aq_telemetry) {
moduleConfig.telemetry.air_quality_screen_enabled = !moduleConfig.telemetry.air_quality_screen_enabled;
toggleTelemetryScreen(moduleConfig.telemetry.air_quality_screen_enabled);
menuHandler::menuQueue = menuHandler::FrameToggles;
screen->runNow();
} else if (selected == show_power) {
moduleConfig.telemetry.power_screen_enabled = !moduleConfig.telemetry.power_screen_enabled;
toggleTelemetryScreen(moduleConfig.telemetry.power_screen_enabled);
menuHandler::menuQueue = menuHandler::FrameToggles;
screen->runNow();
}
Expand All @@ -2688,10 +2669,10 @@ void menuHandler::displayUnitsMenu()
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == MetricUnits) {
config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_METRIC;
service->reloadConfig(SEGMENT_CONFIG);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_NONE);
} else if (selected == ImperialUnits) {
config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL;
service->reloadConfig(SEGMENT_CONFIG);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_NONE);
} else {
menuHandler::menuQueue = menuHandler::ScreenOptionsMenu;
screen->runNow();
Expand All @@ -2713,11 +2694,11 @@ void menuHandler::messageBubblesMenu()
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == ShowBubbles) {
config.display.enable_message_bubbles = true;
service->reloadConfig(SEGMENT_CONFIG);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_NONE);
LOG_INFO("Message bubbles enabled");
} else if (selected == HideBubbles) {
config.display.enable_message_bubbles = false;
service->reloadConfig(SEGMENT_CONFIG);
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_NONE);
LOG_INFO("Message bubbles disabled");
} else {
menuHandler::menuQueue = menuHandler::ScreenOptionsMenu;
Expand Down Expand Up @@ -2934,6 +2915,36 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
menuQueue = MenuNone;
}

// One telemetry screen flag, flipped and persisted. These live in moduleConfig rather than the
// hiddenFrames blob that Screen::toggleFrameVisibility() writes, so they need their own save.
void menuHandler::toggleTelemetryScreen(bool &flag)
{
flag = !flag;
service->applyConfigChange(SEGMENT_MODULECONFIG, CONFIG_APPLY_NONE);
}

// Read live by PositionModule's smart-broadcast path on every send, so no reboot is needed.
void menuHandler::setSmartPositionEnabled(bool enabled)
{
config.position.position_broadcast_smart_enabled = enabled;
service->applyConfigChange(SEGMENT_CONFIG, CONFIG_APPLY_NONE);
}

// Flips the mute bit on a node and persists just the node database. Returns without writing if
// the node is unknown, so a stale pickedNodeNum can't cause a pointless flash write.
void menuHandler::toggleNodeMuted(uint32_t nodeNum)
{
meshtastic_NodeInfoLite *n = nodeDB->getMeshNode(nodeNum);
if (!n)
return;

const bool wasMuted = nodeInfoLiteIsMuted(n);
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_MUTED_MASK, !wasMuted);
LOG_INFO(wasMuted ? "Unmuted node 0x%08x" : "Muted node 0x%08x", nodeNum);
nodeDB->notifyObservers(true);
nodeDB->saveToDisk(SEGMENT_NODEDATABASE); // NodeInfoLite bit: only the node DB changed
}

void menuHandler::saveUIConfig()
{
nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uiconfig);
Expand Down
8 changes: 8 additions & 0 deletions src/graphics/draw/MenuHandler.h
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ class menuHandler
static void hamModeConfirmMenu();
static void licensedToNormalConfirmMenu();

// Config actions, lifted out of their banner-callback lambdas so they are reachable without a
// Screen. The lambdas only ever run via screen->showOverlayBanner(), which is why none of this
// was unit-testable before. Each owns the whole decision: which segment to persist, whether
// the radio needs reconfiguring, and whether a reboot is required.
static void toggleTelemetryScreen(bool &flag);
static void setSmartPositionEnabled(bool enabled);
static void toggleNodeMuted(uint32_t nodeNum); // uint32_t, matching pickedNodeNum above

private:
static void saveUIConfig();
static void keyVerificationInitMenu();
Expand Down
Loading