Status: Implementation complete; hardware validation outstanding
Date: 2026-07-23
Area: src/modules/AdminModule.cpp (handleSetConfig, saveChanges), src/mesh/MeshService.cpp (reloadConfig), src/mesh/NodeDB.h (SEGMENT_*)
Reference: meshtastic#11181 (commit SHAs omitted - they do not survive rebase or squash merge)
Saving a config over the phone/BLE used to do more than persist bytes: it could re-init the LoRa radio and reboot the device, on config that touched neither. This document records what those saves do now, which operations were deliberately left alone, and why.
A config-set admin message carries three independent axes that had become conflated:
- What to persist - the
saveWhatsegment bitmask (which on-disk file to rewrite). - Does the LoRa radio need reconfiguring? - now the explicit
radioAffectedflag. - Does the device need a reboot? - the
requiresRebootflag.
Previously (2) was inferred from (1) (saveWhat & SEGMENT_CONFIG), and (3) defaulted to
true for several sub-messages that never revisited it. Both inferences were wrong for
config that doesn't touch LoRa or doesn't need a restart. They are now decided explicitly and
per-field. Net effect: toggling Bluetooth, changing a WiFi PSK, rotating keys, or nudging a
position broadcast interval no longer re-inits the radio, and several of them no longer reboot.
The guiding rule for what was left alone: fail toward the old behavior. Anything whose live-apply couldn't be proven safe still reloads/reboots exactly as before.
SEGMENT_* (NodeDB.h:87) is a bit selecting one on-disk proto
file - a write unit, not a semantic config domain. SEGMENT_CONFIG is the entire
LocalConfig proto in a single file, covering all eight sub-messages
(device/display/lora/position/power/network/bluetooth/security). Writing it reserializes the
whole struct, so the mask can say which file changed but never which sub-message - it
cannot tell a LoRa change from a Bluetooth one. That is precisely why "does the radio need
reconfiguring?" cannot be inferred from the mask and needs its own signal.
MeshService::reloadConfig(int saveWhat, bool radioAffected = true)
(MeshService.cpp:138) fires the live SX126x reconfigure
(resetRadioConfig() + configChanged.notifyObservers()) only when:
if (radioAffected && (saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS)))The bitmask gate is retained (so the ~35 non-AdminModule callers are unaffected) and
radioAffected only ever suppresses the reload. MeshService::reloadConfig still defaults it
to true, so any external caller that does not opt out keeps the historical behavior.
AdminModule::saveChanges(saveWhat, shouldReboot, radioAffected) threads it through, and there it
has no default - every one of its call sites states the answer. That is not stylistic: see
"Edit transactions" below for why an inherited true stops being harmless once a transaction is
open. handleSetConfig sets radioAffected = true only in the lora_tag case.
| Admin operation | Reloads radio now? | Notes |
|---|---|---|
set_config → lora |
Yes | The only config that feeds RadioInterface::reconfigure() (reads config.lora). Region swaps included. |
set_config → device/position/power/network/display/bluetooth/security |
No (was yes) | Persist SEGMENT_CONFIG, but none affect the modem. |
set_fixed_position / remove_fixed_position |
No (was yes) | Rode SEGMENT_CONFIG only because fixed_position shares the Config file. |
set_module_config |
No | No module config feeds reconfigure(). Previously only the segment mask stopped it. |
set_owner |
No | Node metadata. Still reboots (pre-existing) - that axis was left alone. |
| favorite / ignore / mute node | No | Node-DB bits. The #11146 crash path. |
set_channel |
Yes | Real frequency-slot/PSK change. |
set_ham_mode |
Yes | Rewrites config.lora.tx_enabled and strips channel PSKs. |
Why this matters beyond tidiness: the reconfigure is a live setStandby() + SPI reprogram. It
is invoked from the admin-message handler on the main task (BLE onWrite only queues, so
this is not the BLE callback thread), but the failure is cross-thread: the radio runs its own
off-main worker (RadioLibInterface, a NotifiedWorkerThread) that also drives SPI under the
shared, non-recursive spiLock (a FreeRTOS binary semaphore). The main-task reconfigure
colliding with that worker over the lock / radio state locks the worker up, and the watchdog
reboots the device - the crash seen on the WisMesh Tag favorite (#11146; same non-recursive
spiLock hazard as #10705/#10728). Removing the reconfigure for non-LoRa saves closes the
rest of that crash class and
avoids a needless RX gap on the mesh radio.
handleSetConfig starts requiresReboot = true (:841)
and reboots via saveChanges when it stays true. device, power, and display already
narrowed it (reboot only when a reboot-relevant field changed); position, network, and
bluetooth never did - they rebooted on every set, including a client re-pushing
byte-identical config. Two tiers fixed that.
If the incoming sub-message is byte-identical to the current one, skip the reboot. Uses a
whole-struct memcmp - the right tool for "did anything change?", and fail-safe: its only
error mode is padding bytes differing → an unnecessary reboot (old behavior), never a
missed change. All three sub-messages are POD (no pb_callback_t).
network(:952) andbluetooth(:1136): any real change still reboots (restarts the WiFi/eth or BLE stack).
position (:901) reboots only when a field that
cannot be applied live changed. The live set is exactly what PositionModule reads
directly from config each send/schedule cycle:
| Field | Reboot on change? | Rationale |
|---|---|---|
position_broadcast_secs |
No | Read live in the broadcast scheduler |
position_broadcast_smart_enabled |
No | Read live in the send path |
broadcast_smart_minimum_distance |
No | Read live in the smart-position path |
position_flags |
No | Read live per outgoing position |
fixed_position |
No | Read live; also has dedicated live admin handlers |
gps_mode (ENABLED ↔ DISABLED) |
No | Driver toggled live, as the menus do (see below) |
gps_mode (any NOT_PRESENT transition) |
Yes | Driver object only exists from boot |
gps_enabled |
Yes | GPS driver state |
gps_update_interval, gps_attempt_time |
Yes | GPS subsystem timing |
rx_gpio, tx_gpio, gps_en_gpio |
Yes | GPIO pin (re)assignment |
The gate neutralizes the live fields in a copy and reboots if any other byte differs, so a
newly-added PositionConfig field reboots until it is explicitly cleared as live - fail-safe
for schema growth.
gps_mode is neutralized conditionally, by isLiveGpsModeToggle(), and only because the same
case then calls gps->enable()/gps->disable() - the pattern MenuHandler::GPSToggleMenu()
and InkHUD's TOGGLE_GPS have always used. Suppressing the reboot without driving the
driver would leave the receiver in its old state until the next boot. NOT_PRESENT stays on
the reboot path in both directions: gps is constructed once at boot and only when gps_mode
is not NOT_PRESENT (main.cpp), so there is nothing to enable.
Read this before adding a saveChanges() call site.
A client may bracket a run of admin messages in begin_edit_settings / commit_edit_settings.
While the transaction is open, saveChanges() defers: it writes nothing and returns, so a
multi-field set doesn't hit flash once per field. The commit then performs a single save under a
fixed full-segment mask.
Two consequences, both easy to miss:
-
The per-field decisions had nowhere to go. Everything the two axes above compute happens inside
handleSetConfig, whosesaveChanges()call is the one being deferred. The commit's ownsaveChanges()originally passed nothing, so it took the parameter defaults - reboot and reconfigure, on every commit, whatever was edited. Phone apps write config through transactions, so none of the narrowing above reached them.deferredShouldRebootanddeferredRadioAffectednow accumulate (|=) the deferred answers; the commit consumes them and clears them, so a stray second commit cannot inherit the previous transaction's answer. -
The segment bitmask stops protecting you. Outside a transaction, a node-DB-only save physically cannot reach the radio:
saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS)is zero whateverradioAffectedsays. Inside one, the commit saves under the full mask, so that gate always passes andradioAffectedbecomes the only thing deciding it. An accidentaltrue- inherited from a default, on a call site that never thought about the radio - then reaches the live SX126x reconfigure at commit. Favoriting a node from the phone app would have gone straight back through the #11146 crash path.That is why
AdminModule::saveChangesgivesradioAffectedno default. Do not add one back. The nestedsaveChanges()inside theposition_tagcase is the subtle instance: an inner call on an unrelated segment can opt the whole transaction in.
Covered natively: see the "Edit-transaction deferral" block in test_admin_radio, which asserts
both axes independently across a commit (node-DB, module-config and nested-position batches
reconfigure nothing; a LoRa batch still does; a GPS-mode batch reboots without reconfiguring).
These were audited and deliberately not narrowed. Each still reloads and/or reboots as before. The common thread: the reload/reboot is either genuinely required, or removing it would need real state-tracking / hardware verification that carries more risk than the saving is worth.
-
- now done, see "Edit transactions" below. Initially deferred to its own pass (it needed real state-tracking rather than a one-line gate), then done in the same PR once it became clear the deferral was discarding every decision the rest of this work computes.commit_edit_settings -
network/bluetoothbeyond the Tier-1 no-op gate - a real change still reboots. Why untouched: these restart the WiFi/Ethernet and BLE stacks. Applying a live change to a partially-initialized stack is genuinely hard to get right and high-risk (dropped links, half-configured interfaces) for little reward. The safe, cheap win (skip the reboot on no-op re-pushes) was taken; live-apply was not. -
GPS-timing position fields (
gps_mode,gps_enabled,gps_update_interval,gps_attempt_time) - still reboot. Why untouched: they touch the GPS subsystem/driver and could not be statically proven to apply live (unlike the cadence/flag fields, which are read straight fromconfigeach cycle). Following the fail-toward-reboot rule, they stay on the reboot path. Moving them to the live set is exactly what a hardware verification pass is for - confirm on a real node that the new value takes effect with no restart, then reclassify. -
handleSetModuleConfig(:1215) - every module-config set reboots (shouldReboot = true). Why untouched: most module sets start or stop background threads (telemetry, serial, MQTT, store-and-forward, detection sensor), where a reboot is genuinely warranted. Separating the few that plausibly don't need one (canned messages, ambient lighting, status message) needs a per-module audit - its own effort, not part of this work. -
On-device menu- now done, see "On-device menus" below. These were initially left alone as out of scope, then migrated in the same PR.reloadConfigsites
For contrast, set_channel (:1445) and
restore_preferences (:1911) also still reload -
but that is correct, not conservative: both genuinely change LoRa/channel state.
Both UIs go through one entry point on MeshService, so a menu action states the same two
decisions AdminModule does rather than inheriting defaults:
void applyConfigChange(int saveWhat, uint8_t flags, int32_t rebootSeconds = DEFAULT_REBOOT_SECONDS);
enum ConfigApplyFlags : uint8_t {
CONFIG_APPLY_NONE = 0, // persist only
CONFIG_APPLY_RADIO = 1 << 0, // region/preset/freq/channel/PSK - re-init the LoRa chip
CONFIG_APPLY_REBOOT = 1 << 1, // field only takes effect after a restart
};flags has no default: the point is that every call site says what it wants. A flags enum
rather than two bools because reboot and radioAffected are both bools, sat in different
positions across the helpers this replaced, and transposing them compiled silently. InkHUD's
applyConfigReload(changes, reboot) took reboot exactly where reloadConfig and
saveChanges take radioAffected.
Notes for anyone extending this:
reloadConfig(X, /*radioAffected=*/false)is exactly equivalent tosaveToDisk(X)- the save is unconditional, outside the guard. So never pair them: that writes the file twice.- AdminModule keeps using
saveChanges, notapplyConfigChangedirectly. It owns the edit-transaction deferral, without which a multi-field remote set writes flash per field. - Reboots go through
requestReboot()(src/main.h). It carries no UI: BaseUI already renders the notice at draw time fromrebootAtMsec, while InkHUD's e-ink only draws when pushed and so raisesnotifyApplyingChanges()explicitly. rebootSecondsexists for one caller - InkHUD's wifi-recovery path uses a shorter delay thanDEFAULT_REBOOT_SECONDS. A negative value cancels a pending reboot rather than bringing it forward (rebootAtMsec == 0means "none pending" everywhere it is read), matchingadmin.proto'sreboot_seconds.- Every
CONFIG_APPLY_REBOOTsite in the InkHUD menu pairs with anotifyApplyingChanges(), and must keep doing so - see the two-jobs note on that method inInkHUD.h. CONFIG_APPLY_RADIOmeans the modem, not the segment. The channel menu's uplink/downlink andposition_precisionactions writeSEGMENT_CHANNELSbut touch no name, PSK or frequency slot, so they persist only; the frequency the radio derives comes from the name+PSK hash, andRadioInterfaceis the sole observer ofconfigChanged. They carried the flag at first purely because the oldreloadConfig(SEGMENT_CHANNELS)inferred it from the bitmask.- UI-only state stays out of this: BaseUI
uiconfigfields usesaveUIConfig(), and InkHUD has its own non-protobufPersistence::Settingsstore. Neither is in the/prefsprotobuf tree.
Native coverage lives in test/test_admin_radio/test_main.cpp:
- Radio reload: each non-LoRa
set_configsub-message assertsconfigChangeddoes not fire (ConfigChangedCounter);loraandset_channelassert it still does; directreloadConfigguards pin the fail-safe default and the explicit opt-out. - Reboot: each of position/network/bluetooth asserts
rebootAtMsecstays0on a no-op set and is armed on a real change; a position broadcast-interval change asserts no reboot, whilegps_modeandrx_gpiochanges assert a reboot. - Edit transactions: the same two axes asserted across a
begin/ set /commitsequence, where the segment bitmask no longer backstopsradioAffected. Node-DB, module-config and nested-position batches must commit without reconfiguring; a LoRa batch must still reconfigure (and not reboot); agps_modebatch must reboot without reconfiguring. Also pinned: nothing is written until the commit, the flags don't leak into the next transaction, and a commit with no matching begin is inert.
The harness defers reboot() to rebootAtMsec (it does not exit), which is the signal the
reboot tests assert on.
Native tests cannot observe the actual side effects - the live SX126x SPI sequence and a real reboot don't happen in the host build - so the two claims that matter most (the crash is gone; "live" fields really apply without a restart) require hardware. See below.
Run against a real node through the
meshtastic-mcp harness
(MESHTASTIC_FIRMWARE_ROOT → this checkout), with the serial log open. A nRF52840 SX126x
board (e.g. WisMesh Tag) is the reference target; the crash was reproduced there.
The scenarios below cover the AdminModule / client-protocol path only. The on-device menu path was also migrated by this PR (see "On-device menus" above) and is a genuinely separate code path - it needs its own validation pass, driven by the buttons and screen on the board, and none of the transport reasoning in this section extends to it.
Transport for the admin path: serial is sufficient, and BLE is not needed. These
operations are all client-protocol admin messages (ToRadio), carried identically over serial
(SerialConsole/StreamAPI) or BLE. Crucially, on nRF52 the BLE onWrite callback only
queues the packet - handleToRadio → saveChanges → applyConfigChange (the reconfigure)
runs on the main FreeRTOS task (NimbleBluetooth.cpp:135),
exactly where SerialConsole (an OSThread) services the serial stream. So the code and
thread under test are the same whichever transport you use, and the original crash was
serial-proven. Drive the admin messages over USB serial (e.g. the meshtastic --port CLI);
use BLE only if you specifically want to reproduce the exact user-facing conditions.
The menu path reaches the same MeshService::applyConfigChange on the same main task, so it
inherits the same reasoning about what runs - but only a menu-driven pass exercises the
menu call sites' own flag choices, which is what this PR changed there.
Confirms the non-LoRa config saves no longer run the live radio reconfigure - the main-task
setStandby() + SPI reprogram that collided with the radio's off-main worker thread over the
non-recursive spiLock and locked it up, tripping the watchdog reboot on the WisMesh Tag
favorite (#11146).
Perform each of: toggle Bluetooth enabled, change the WiFi PSK, rotate the security
keypair, favorite/unfavorite a node.
- Pass: no radio re-init in the serial log before any reboot (no modem-reconfigure /
setStandby/ re-initlines), and no watchdog reboot-loop. Saves that legitimately reboot (WiFi, keypair) do so cleanly, after the save - the reboot is fine; a live reconfigure before it is the failure. - Positive control: a
loraconfig change and aset_channelshould show the reconfigure - proves the path still fires when it should.
Then repeat the favorite/unfavorite case wrapped in begin_edit_settings /
commit_edit_settings - that is the shape a phone app actually sends, and until the deferred
flags landed it was the one shape where the segment bitmask could not stop the reconfigure (see
"Edit transactions"). Pass criteria are the same, measured at the commit. Watch for a
reconfigure that appears only at the commit, not during the individual sets.
The only reason to touch a node for the reboot work: decide whether the GPS-timing fields
left on the reboot path (gps_enabled, gps_update_interval, gps_attempt_time - item 3
above) can actually apply live and be reclassified.
gps_mode has already been reclassified for the ENABLED ↔ DISABLED pair, on the strength
of the driver call now made alongside it. That one still wants a hardware pass: toggle GPS
off and on from the app and confirm the receiver actually stops and restarts (fix lost, then
reacquired) with no reboot banner - the failure mode to look for is config saying one thing
while the receiver keeps doing the other.
For each candidate field, one at a time, on a GPS-equipped node: change only that field (over serial, as above) and observe.
- Pass (→ reclassify as live): the new value takes visible effect (e.g. GPS poll cadence
changes, mode switches) and no reboot banner appears. Only then add the field to the
live set in the
position_taggate and a native no-reboot case. - Fail (→ leave rebooting): the value only takes effect after a restart, or the node reboots. This is the expected default - fail toward rebooting; do not reclassify on native evidence alone.
Fields already shipped as live (position_broadcast_secs, position_broadcast_smart_enabled,
broadcast_smart_minimum_distance, position_flags, fixed_position) were proven live by
static analysis; a spot-check that a broadcast-interval change takes effect with no reboot is
a cheap confidence test but not a gate.
- To stop a config field from reloading the radio on an AdminModule/client config save: it
already doesn't, unless it's
lora. Do not widenradioAffectedto non-LoRa config - onlyRadioInterface::reconfigure()(which readsconfig.lora) consumes it. The on-device menus are now gated the same way, viaapplyConfigChange(see "On-device menus" above). - To move a field off the reboot path: confirm it is consumed live (read from
configat use time, no cached/driver state), add it to the live set in the relevanthandleSetConfigcase, and add a native case asserting no reboot on its change. For anything driver- or hardware-backed, verify on real hardware first. When unsure, leave it rebooting.