Skip to content

fix(admin): persist TAK module config (team color / member role) - #11216

Merged
thebentern merged 1 commit into
developfrom
fw-6430-handoff
Jul 25, 2026
Merged

fix(admin): persist TAK module config (team color / member role)#11216
thebentern merged 1 commit into
developfrom
fw-6430-handoff

Conversation

@jamesarich

@jamesarich jamesarich commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Setting TAK Team Color or Member Role from a client appeared to save — the node ACKed and rebooted — but both values came back "Unspecified" after every reboot. The client apps were sending a correct set_module_config with the ModuleConfig.tak submessage populated; the firmware had no write path for it.

Fixes #11182
Closes #9691
Fixes meshtastic/Meshtastic-Android#6430

Root cause — three gaps, each silent on its own

  1. AdminModule::handleSetModuleConfig() had no meshtastic_ModuleConfig_tak_tag case. The submessage fell through the switch, and the handler still ran saveChanges(SEGMENT_MODULECONFIG, shouldReboot) and returned true — so the node ACKed success and rebooted having stored nothing.
  2. NodeDB::saveToDisk() never set moduleConfig.has_tak. Even a correctly populated struct would be omitted by nanopb on the flash write, since LocalModuleConfig.tak is an optional field gated on its presence flag.
  3. AdminModule::handleGetModuleConfig() had no TAK_CONFIG case, so remote admin reads returned an empty response. (The local PhoneAPI read path was already fixed in PhoneAPI: add missing tak_tag case + skip reserved gap in module-config iteration #10256; its PR description stated the admin set path was handled, but it was not — this PR completes that. enhancement: Add on-device TAK team/role configuration #9716 would have wired this up but was closed unmerged.)

Changes

  • src/modules/AdminModule.cpp — add the tak case to handleSetModuleConfig() (copies the submessage, sets has_tak) and the TAK_CONFIG case to handleGetModuleConfig()
  • src/mesh/NodeDB.cpp — set moduleConfig.has_tak = true alongside the other presence flags in the SEGMENT_MODULECONFIG save block

Tests

Two new native suites (registered in test/native-suite-count, docs updated):

  • test_module_config — the bug class generalized: every ModuleConfig submessage is swept through the full lifecycle (admin set → encode, as saveToDisk would → decode into the live global, as a reboot would → admin get answers with that submessage, for remote and local requesters), enumerating the generated proto constants so a future submessage is covered without registration. A companion test asserts sequential sets don't clobber earlier submessages.
  • test_tak_config — TAK value fidelity: team/role stored verbatim, clearing back to Unspecified persists as an explicit edit, values survive the save/load round-trip, and both remote and local TAK_CONFIG reads return them.

Both suites fail on the pre-fix code naming the offending submessage (dropped on save, presence flag not set: ModuleConfig submessage tag 16) and pass with the fix. Full native suite: all 40 suites pass, including under ASan/LSan via bin/test-native-docker.sh.

Notes for reviewers

  • saveToDisk() also never forces has_remote_hardware / has_traffic_management. That is benign today (their admin set cases set the flags, so user-set values persist) and left untouched here to keep this change scoped to TAK.
  • Meshtastic-Android#6430 has an app-side follow-up independent of this fix: Capabilities.kt gates supportsTakConfig on protobuf availability (>= 2.7.19) rather than on a firmware version that actually implements the write path, so the app offered a working-looking editor (plus a reboot) against firmware that ignored it. Worth a version-floor bump on the app side once this ships.

Hardware verification (Seeed Wio Tracker L1, nRF52840)

  • Pre-fix repro on stock develop (2.8.0.ef1aedd): set_module_config(tak) with Team=Cyan / Role=Medic was ACKed and the node rebooted, but the values read back as Unspecified — the reported symptom, reproduced.
  • Post-fix (2.8.0.bb9c72e, this branch): the same set lands in RAM (admin TAK_CONFIG get returns Cyan/Medic before the reboot), survives the save + reboot (post-reboot admin get returns Cyan/Medic), and the PhoneAPI config dump sends moduleConfig { tak { team: Cyan role: Medic } } on the wire.
  • Side finding, not a firmware issue: the meshtastic Python lib (2.7.11) drops the tak and statusmessage sections when accumulating node.moduleConfig from the config dump, so --get-style reads through that lib show stale zeros even though the wire traffic is correct.

🤝 Attestations

  • I have tested that my proposed changes behave as described.
  • I have tested that my proposed changes do not cause any obvious regressions on the following devices:
    • Heltec (Lora32) V3
    • LilyGo T-Deck
    • LilyGo T-Beam
    • RAK WisBlock 4631
    • Seeed Studio T-1000E tracker card
    • Other (please specify below): Seeed Wio Tracker L1 (nRF52840) — full set/save/reboot/get cycle verified on-device; see Hardware verification above. Plus the native (portduino) suite, 40/40 suites under ASan/LSan.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for configuring and retrieving TAK/ATAK team and role settings.
    • TAK settings now remain available after saving and restarting.
    • Module configuration updates preserve previously saved settings across save and load operations.
  • Bug Fixes

    • Fixed module configuration handling so TAK settings are correctly stored, restored, and returned when requested.
  • Tests

    • Added coverage for TAK settings and complete module configuration persistence.

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

⚡ Try this PR in the Web Flasher

Flash this PR in the Web Flasher

firmware commit boards expires

Warning

This is an automated, unreviewed CI test build. Back up your device configuration
before flashing, and only flash devices you are able to recover.

Supported boards built by this PR (31)
Device Board Platform
Crowpanel Adv 3.5 TFT elecrow-adv-35-tft esp32-s3
Heltec HT62 heltec-ht62-esp32c3-sx1262 esp32-c3
Heltec Mesh Node 096 heltec-mesh-node-t096 nrf52840
Heltec Mesh Node T1 heltec-mesh-node-t1 nrf52840
Heltec Mesh Node T114 heltec-mesh-node-t114 nrf52840
Heltec V3 heltec-v3 esp32-s3
Heltec V4 heltec-v4 esp32-s3
Meshnology W10 meshnology_w10 esp32-s3
Meshnology W12 meshnology_w12 esp32-s3
Raspberry Pi Pico pico rp2040
Raspberry Pi Pico W picow rp2040
RAK WisMesh Pocket V3 rak_wismesh_pocket nrf52840
RAK WisMesh Pod rak_wismesh_pod nrf52840
RAK WisMesh Repeater Mini V2 rak_wismesh_repeater_mini nrf52840
RAK WisMesh Tag rak_wismeshtag nrf52840
RAK WisBlock 11200 rak11200 esp32
RAK WisBlock 11310 rak11310 rp2040
RAK3312 rak3312 esp32-s3
RAK WisBlock 4631 rak4631 nrf52840
Seeed SenseCAP Mesh-Tracker-X1 seeed_mesh_tracker_X1 nrf52840
Seeed Wio Tracker L1 seeed_wio_tracker_L1 nrf52840
Seeed Xiao NRF52840 Kit seeed_xiao_nrf52840_kit nrf52840
Seeed Xiao ESP32-S3 seeed-xiao-s3 esp32-s3
Station G2 station-g2 esp32-s3
Station G3 station-g3 esp32-s3
LILYGO T-Deck t-deck-tft esp32-s3
LILYGO T-Echo t-echo nrf52840
LILYGO T-Echo Plus t-echo-plus nrf52840
LILYGO T-Impulse Plus t-impulse-plus nrf52840
LilyGo T3-C6 tlora-c6 esp32-c6
Seeed SenseCAP T1000-E tracker-t1000-e nrf52840

Build artifacts expire on 2026-08-24. Updated for bb9c72e.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e03e78dc-c382-4428-bcfe-24e056331fcb

📥 Commits

Reviewing files that changed from the base of the PR and between b9a5443 and bb9c72e.

📒 Files selected for processing (7)
  • .github/copilot-instructions.md
  • src/mesh/NodeDB.cpp
  • src/modules/AdminModule.cpp
  • test/README.md
  • test/native-suite-count
  • test/test_module_config/test_main.cpp
  • test/test_tak_config/test_main.cpp

📝 Walkthrough

Walkthrough

TAK module configuration now supports admin set/get handling and persistence. New native tests cover TAK values and all compiled-in module configuration submessages across save/load and readback flows.

Changes

TAK module configuration lifecycle

Layer / File(s) Summary
TAK set, get, and persistence handling
src/modules/AdminModule.cpp, src/mesh/NodeDB.cpp
Admin set/get paths now handle TAK configuration, and disk serialization marks TAK data as present.
Module configuration lifecycle coverage
test/test_module_config/test_main.cpp
Tests validate submessage mappings, save/load fidelity, get responses, sequential updates, and test harness setup.
TAK fidelity tests and suite registration
test/test_tak_config/test_main.cpp, test/README.md, .github/copilot-instructions.md, test/native-suite-count
TAK team and role values are tested through set, clear, save/load, and get flows; the suites are documented and counted.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested labels: module, needs-review

Suggested reviewers: nomdetom, thebentern, jp-bennett

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main fix: persisting TAK module config values for team color and member role.
Description check ✅ Passed The description covers the bug, root cause, code changes, tests, and attestations, matching the repository template well.
Linked Issues check ✅ Passed The changes address the linked issues by adding TAK set/get handling, persisting presence flags, and verifying value survival.
Out of Scope Changes check ✅ Passed The extra docs and test-count updates support the new test suites and stay within the PR's testing and TAK persistence scope.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fw-6430-handoff

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jamesarich jamesarich added bugfix Pull request that fixes bugs 2.8 labels Jul 25, 2026
Setting TAK team/role ACKed and rebooted but stored nothing:
handleSetModuleConfig had no tak case, saveToDisk never set has_tak,
and handleGetModuleConfig had no TAK_CONFIG case. Add all three, plus
a native suite sweeping every ModuleConfig submessage through
set -> save -> load -> get and a TAK value-fidelity suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@thebentern
thebentern added this pull request to the merge queue Jul 25, 2026
Merged via the queue into develop with commit 8e104a9 Jul 25, 2026
102 checks passed
madeofstown pushed a commit to madeofstown/meshtastic-firmware that referenced this pull request Jul 26, 2026
…htastic#11216)

Setting TAK team/role ACKed and rebooted but stored nothing:
handleSetModuleConfig had no tak case, saveToDisk never set has_tak,
and handleGetModuleConfig had no TAK_CONFIG case. Add all three, plus
a native suite sweeping every ModuleConfig submessage through
set -> save -> load -> get and a TAK value-fidelity suite.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Jul 26, 2026
Four review findings.

broadcast_smart_minimum_interval_secs is not read live, despite the comment this
branch added claiming it was. PositionModule::minimumTimeThreshold is a const
data member initialised when the module is constructed, so the value is captured
at boot; only the sibling distance field is genuinely re-read (PositionModule.cpp
:630). The InkHUD menu therefore applied it with no reboot and it silently did
nothing until the next restart. It now reboots, matching the AdminModule path.
Note the review suggested the opposite fix - adding the field to AdminModule's
live-field list - which would have made the admin path silently ineffective too.

While an edit transaction is open saveChanges() defers the write, which threw
away the per-field reboot and radio decisions; the commit then used the parameter
defaults and always rebooted and reconfigured the radio. Since phone apps write
config through transactions, none of this branch's narrowing reached them. The
deferred decisions now accumulate and the commit honours them.

Two test fixes: the set_ignored_node test relied on an earlier RUN_TEST having
created the node, which breaks under -f or a registration reorder. And the
telemetry test asserted only values it had just assigned - replaced with one that
drives the real toggle and checks the save path sets has_telemetry, the flag
whose absence caused the TAK persistence bug fixed upstream in meshtastic#11216.

clod helped too
NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Jul 27, 2026
Four review findings.

broadcast_smart_minimum_interval_secs is not read live, despite the comment this
branch added claiming it was. PositionModule::minimumTimeThreshold is a const
data member initialised when the module is constructed, so the value is captured
at boot; only the sibling distance field is genuinely re-read (PositionModule.cpp
:630). The InkHUD menu therefore applied it with no reboot and it silently did
nothing until the next restart. It now reboots, matching the AdminModule path.
Note the review suggested the opposite fix - adding the field to AdminModule's
live-field list - which would have made the admin path silently ineffective too.

While an edit transaction is open saveChanges() defers the write, which threw
away the per-field reboot and radio decisions; the commit then used the parameter
defaults and always rebooted and reconfigured the radio. Since phone apps write
config through transactions, none of this branch's narrowing reached them. The
deferred decisions now accumulate and the commit honours them.

Two test fixes: the set_ignored_node test relied on an earlier RUN_TEST having
created the node, which breaks under -f or a registration reorder. And the
telemetry test asserted only values it had just assigned - replaced with one that
drives the real toggle and checks the save path sets has_telemetry, the flag
whose absence caused the TAK persistence bug fixed upstream in meshtastic#11216.

clod helped too
NomDeTom pushed a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Jul 30, 2026
Four review findings.

broadcast_smart_minimum_interval_secs is not read live, despite the comment this
branch added claiming it was. PositionModule::minimumTimeThreshold is a const
data member initialised when the module is constructed, so the value is captured
at boot; only the sibling distance field is genuinely re-read (PositionModule.cpp
:630). The InkHUD menu therefore applied it with no reboot and it silently did
nothing until the next restart. It now reboots, matching the AdminModule path.
Note the review suggested the opposite fix - adding the field to AdminModule's
live-field list - which would have made the admin path silently ineffective too.

While an edit transaction is open saveChanges() defers the write, which threw
away the per-field reboot and radio decisions; the commit then used the parameter
defaults and always rebooted and reconfigured the radio. Since phone apps write
config through transactions, none of this branch's narrowing reached them. The
deferred decisions now accumulate and the commit honours them.

Two test fixes: the set_ignored_node test relied on an earlier RUN_TEST having
created the node, which breaks under -f or a registration reorder. And the
telemetry test asserted only values it had just assigned - replaced with one that
drives the real toggle and checks the save path sets has_telemetry, the flag
whose absence caused the TAK persistence bug fixed upstream in meshtastic#11216.

clod helped too
NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Jul 30, 2026
* reconfigure tests

* Narrow radio-reload trigger to LoRa-affecting saves only

reloadConfig() inferred "the radio needs reconfiguring" from whether
saveWhat included SEGMENT_CONFIG. But Config is a monolithic segment
(device/position/power/network/display/lora/bluetooth/security share one
file), so every non-LoRa AdminModule save still fired the live SX126x
reconfigure - the same live-SPI-on-the-admin-thread sequence that crashes
on a favorite (meshtastic#11146), just reached via a Bluetooth toggle, WiFi PSK
change, or keypair rotation.

Separate the two concerns: add an explicit radioAffected flag threaded
through AdminModule::saveChanges() into MeshService::reloadConfig(). The
gate becomes `radioAffected && (saveWhat & (SEGMENT_CONFIG|SEGMENT_CHANNELS))`
- the bitmask inference is kept as a backstop and only suppressible, and
radioAffected defaults to true so the ~35 existing reloadConfig() callers
(MenuHandler, MenuApplet, portduino) keep today's behavior untouched.

Only handleSetConfig()'s lora_tag sets radioAffected; every other
sub-message, plus set_fixed_position/remove_fixed_position and position's
nested save, opt out. set_channel/commit_edit_settings/restore keep the
default and still reload.

Add native coverage in test_admin_radio for each non-LoRa sub-message
(asserting no reconfigure), regression guards that lora/set_channel still
reconfigure, and direct reloadConfig() guards pinning the fail-safe default.

* mark up todo sites for checking reloadConfig use

* added clarifying note

* Don't reboot on no-op position/network/bluetooth config sets

handleSetConfig() starts requiresReboot=true and device/power/display each
gate it back down when nothing reboot-worthy changed, but position,
network, and bluetooth never touched requiresReboot - so they rebooted on
*every* set, including a client re-pushing byte-identical config (which
happens routinely on connect/retry).

Add a no-op gate to those three: if the incoming sub-message is
byte-identical to the current one, skip the reboot. A whole-struct memcmp
is the right tool here (unlike the field-by-field device/power/display
gates, which must ignore benign fields) - it answers "did anything change?"
and fails safe: its only error mode is padding-byte differences causing an
unnecessary reboot, never a missed change. All three sub-messages are POD
(no pb_callback_t fields). Any real change still reboots exactly as before.

This is Tier 1 of plan-narrow-reboot-trigger; Tier 2 will further narrow
position to reboot only on boot-only fields (GPIO/GPS).

Adds native coverage asserting rebootAtMsec stays unset on a no-op set and
is armed on a real change, for each of the three sub-messages.

* Apply live position config changes without a reboot

Tier 2 of plan-narrow-reboot-trigger. A position set that touches only
fields the position module consumes live now applies without restarting.

PositionModule reads position_broadcast_secs, position_broadcast_smart_enabled,
broadcast_smart_minimum_distance, position_flags, and fixed_position directly
from config on every send/schedule cycle (fixed_position also has dedicated
live admin handlers) - changing only those needs no reboot. Everything else
stays on the reboot path: GPS driver state (gps_mode/gps_enabled/
gps_update_interval/gps_attempt_time) and GPIO pin assignments (rx_gpio/
tx_gpio/gps_en_gpio), all of which touch subsystem/hardware init.

The live set is deliberately limited to what static analysis proves is
applied live, so this ships without hardware verification; GPS-timing fields
that might also be live were left rebooting (fail toward current behavior).
The gate neutralizes the live fields in a copy and reboots if any other byte
differs, so a future PositionConfig field reboots until explicitly cleared
as live - fail safe for schema growth.

This supersedes the Tier 1 no-op memcmp gate for position (network/bluetooth
keep theirs). Native coverage: a broadcast-interval change does not schedule
a reboot; gps_mode and rx_gpio changes still do.

* docs: config-save radio-reload & reboot gating

Document the AdminModule config-save side-effect work: the radioAffected
and requiresReboot axes, which operations now skip the radio reload or the
reboot, and the operations deliberately left unchanged (commit_edit_settings,
network/bluetooth live-apply, GPS-timing position fields, module config,
and the on-device menu reloadConfig sites) with the reason for each.

* docs: add hardware testing section to config-save gating

The doc only mentioned the outstanding GPS-timing pass in one line and
omitted the radio-reload/crash validation entirely, with no procedure.
Add a Hardware testing section covering: the meshtastic-mcp setup
(BLE connected throughout, nRF52840 SX126x reference board); the
radio-reload/crash regression guard for the favorite-node fix (with a
lora/set_channel positive control); and a per-field procedure with
pass/fail criteria for deciding whether GPS-timing position fields can be
reclassified as live - fail toward rebooting.

* docs: clarify hardware tests run over serial, not menus/BLE

The hardware section implied a BLE connection was required and that the
crash ran on the BLE callback thread. On nRF52 the BLE onWrite only queues;
handleToRadio -> saveChanges -> reloadConfig runs on the main FreeRTOS task,
same context SerialConsole uses. So serial drives the exact code/thread
under test, the on-device menus are unrelated (separate path), and the
original crash was serial-proven. State that serial is sufficient and
correct the thread references accordingly.

* docs: correct crash mechanism to the off-main-thread spiLock lockup

The previous edit, while right that the reconfigure is invoked on the main
task (not the BLE callback thread), oversimplified the failure. The crash
is cross-thread: the main-task setStandby()+SPI reprogram collides with the
radio's off-main NotifiedWorkerThread over the shared non-recursive spiLock
(FreeRTOS binary semaphore), locking it up and tripping the watchdog reboot
(meshtastic#11146; same spiLock hazard as meshtastic#10705/meshtastic#10728). Restore that in both the
Axis 1 rationale and the hardware test-1 description. Does not change the
serial-is-sufficient conclusion (the collision is transport-independent).

* docs: clarify config save behavior for GPS position updates

* menu actions

* menu actions reboot

* Persist telemetry screen toggles from the frame menu

The three moduleConfig.telemetry.*_screen_enabled toggles in the frame-toggle
menu changed the value in memory and never wrote it, so they reverted on any
reboot that did not happen to go through the reboot menu (which saves every
segment). Their sibling entries in the same menu persist via
Screen::toggleFrameVisibility -> saveFrameVisibility, so half the menu was
durable and three entries were not.

These three live in moduleConfig rather than the hiddenFrames blob, so they need
their own SEGMENT_MODULECONFIG save. radioAffected is false: a screen preference
has no business re-initialising the LoRa chip.

clod helped too

* Drop redundant and over-broad config writes from the menus

reloadConfig() ends with an unconditional saveToDisk(saveWhat), outside the
radioAffected guard, so nine sites in the InkHUD menu were writing the same
proto file twice in a row: saveToDisk(X) immediately followed by
reloadConfig(X, ...). One of the nine is the shared applyConfigReload() helper,
so thirteen menu actions were affected.

Five sites in the BaseUI menu called saveUIConfig() after changing a field that
lives in config.proto. saveUIConfig() only writes /prefs/uiconfig.proto, so
those writes could not persist the changed field and did nothing but cost a
flash write. GPSFormatMenu had the mirror image: a uiconfig-only change that
also called reloadConfig(), rewriting config.proto for a field not in it.

Three bare saveToDisk() calls rewrote all five segments to change one bit -
a channel mute flag and two NodeInfoLite bits - so they now pass the segment
they actually touch. NodeInfoLite is written by saveNodeDatabaseToDisk() under
SEGMENT_NODEDATABASE, not SEGMENT_DEVICESTATE. The reboot menu keeps its bare
call: a full flush before a deliberate reboot is correct.

reloadConfig() becomes virtual so tests can count calls; the accompanying test
pins down the saveToDisk equivalence the nine deletions rely on.

clod helped too

* Route config saves through one MeshService::applyConfigChange helper

Applying a config change involves three independent decisions: which proto files
to persist, whether the LoRa chip needs re-initialising, and whether the field
only takes effect after a restart. Those were spread across four helpers with
three different parameter orders, two of which took adjacent bools that compile
fine when transposed. InkHUD's applyConfigReload was the sharp edge: its second
parameter was `reboot`, sitting exactly where reloadConfig and saveChanges take
`radioAffected`.

There is now a single entry point taking a flags enum, so each call site states
its intent and cannot silently mean the opposite. Migrated 23 BaseUI sites, 20
InkHUD sites and the wasm glue. applyConfigReload is deleted and its five
callers inlined; applyLoRaRegion and applyLoRaPreset stay, since they hold real
domain logic beyond bundling, and just delegate.

Adjacent `rebootAtMsec = millis() + ...` assignments fold into the reboot flag.
The one caller that deliberately uses a shorter delay keeps it via the trailing
rebootSeconds parameter. Reboot scheduling itself moves into requestReboot() in
main.cpp, next to the global it sets: previously only AdminModule had a helper
for this, and it was private, which is why every menu open-coded the deadline.
requestReboot carries no UI, because BaseUI already renders the notice at draw
time whenever rebootAtMsec is set.

saveChanges keeps its signature and its edit-transaction deferral, so its
fourteen call sites and the existing gating tests are untouched.

clod helped too

* Extract menu config actions into testable functions

None of the menu save behaviour was reachable from a test: every action lived in
a lambda assigned to BannerOverlayOptions.bannerCallback, which only ever runs
via screen->showOverlayBanner(), so exercising one needed a live Screen. That is
why the defects this series fixes went unnoticed - MenuHandler.cpp compiles in
the native test build, but nothing in it could be called.

Three actions move out into functions that own the whole decision: which segment
to persist, whether the radio needs reconfiguring, and whether to reboot. Chosen
because each covers a defect this series touched - the telemetry toggles that
never persisted, the smart-position reboot that PR meshtastic#11181 removed, and a node-DB
bit write that must not reach the radio.

Deliberately picked extractions that also deduplicate: three telemetry call
sites collapse onto one function and two smart-position sites onto another, so
the promicro image is byte-identical to before. Worth knowing, because that board
has under 1.4 KB of headroom before the warmstore region.

toggleNodeMuted also gains a guard for an unknown node, so a stale
pickedNodeNum can no longer cause a pointless flash write.

clod helped too

* Sort out InkHUD applying-changes notifications

notifyApplyingChanges() was doing two jobs at its call sites in MenuApplet.cpp:
signalling a live change that is applied without restarting, and warning of an
imminent reboot. Audited every site. The finding is that none of them are
redundant, so this records why rather than removing anything.

The two live-change calls - LoRa region and modem preset - cover the seconds the
e-ink takes to redraw while the radio reconfigures, and nothing else raises them.
The reboot-path calls warn before the display goes. requestReboot() cannot absorb
those: it deliberately carries no UI, because BaseUI renders its own notice at
draw time from rebootAtMsec, while e-ink only draws when pushed.

Also worth recording: the existing notifyReboot Observable (sleep.h, fired from
Power::reboot) is a different moment - reboot execution, not scheduling - and
InkHUD already observes it to save settings and shut applets down. A new
"reboot scheduled" observable to centralise these calls would be a third reboot
signal for no functional gain, so it is not added.

clod helped too

* stylee

* Fix stale doc references and document the menu path

Addresses review feedback.

Four comments pointed at plan documents that were never committed
(plan-narrow-reboot-trigger.md, plan-decouple-nodedb-admin-saves.md), so the
rationale they cited was not discoverable. They now point at
docs/admin-config-save-gating.md, which is in-repo.

The doc listed the commit SHAs it described. All five had already been orphaned
by the rebases this branch has been through, exactly as predicted, so it cites
the PR instead. "Status: Implemented" also overstated things while hardware
validation is still outstanding.

The doc's biggest problem was that it had gone out of date within its own PR: it
described the on-device menus as an untouched code path that still reloads the
radio on any Config save, with per-site TODO markers. That stopped being true
when the menus moved onto applyConfigChange. Replaced with a section covering the
new entry point, the flags, why they are a flags enum rather than two bools, and
the saveToDisk equivalence that makes pairing the two calls a double write.

clod helped too

* Fix smart-broadcast-interval reboot and carry transaction flags

Four review findings.

broadcast_smart_minimum_interval_secs is not read live, despite the comment this
branch added claiming it was. PositionModule::minimumTimeThreshold is a const
data member initialised when the module is constructed, so the value is captured
at boot; only the sibling distance field is genuinely re-read (PositionModule.cpp
:630). The InkHUD menu therefore applied it with no reboot and it silently did
nothing until the next restart. It now reboots, matching the AdminModule path.
Note the review suggested the opposite fix - adding the field to AdminModule's
live-field list - which would have made the admin path silently ineffective too.

While an edit transaction is open saveChanges() defers the write, which threw
away the per-field reboot and radio decisions; the commit then used the parameter
defaults and always rebooted and reconfigured the radio. Since phone apps write
config through transactions, none of this branch's narrowing reached them. The
deferred decisions now accumulate and the commit honours them.

Two test fixes: the set_ignored_node test relied on an earlier RUN_TEST having
created the node, which breaks under -f or a registration reorder. And the
telemetry test asserted only values it had just assigned - replaced with one that
drives the real toggle and checks the save path sets has_telemetry, the flag
whose absence caused the TAK persistence bug fixed upstream in meshtastic#11216.

clod helped too

* Stop the edit transaction discarding the per-field save decisions

Review of this branch's own output, plus the two findings Copilot raised on

The headline defect is that none of this branch's narrowing reached phone apps.
saveChanges() defers while an edit transaction is open, and radioAffected
defaulted to true, so eight call sites that never thought about the radio - the
five node-DB handlers, set_owner, set_module_config, and the nested save in the
position case - accumulated a true into deferredRadioAffected. Outside a
transaction that was harmless, because reloadConfig()'s
saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS) bitmask independently blocks a
node-DB-only save from reaching the radio whatever it asks for. Inside one the
commit saves under a fixed full mask, so the bitmask always passes and
radioAffected is the only thing left deciding it. Favouriting a node from the
phone app therefore still ran the live SX126x reconfigure at commit - the

The fix is to remove the default from saveChanges() rather than gate the
deferred flag on the segment mask, which is what the review suggested. Gating
would reinstate exactly the segment-to-radio inference this branch exists to
delete - NodeDB.h now carries a comment telling the next person not to do that -
and it fixes the symptom while leaving the wrong default in place for the next
call site. With no default the compiler makes all fourteen sites state the
answer. The commit also consumes and clears the deferred flags, so a stray
second commit cannot inherit the previous transaction's answer.

That gap existed because every radio test ran outside a transaction, which is
the one arrangement where the bug is unreachable. Nine tests now cover the
deferred path, asserting both axes independently across a commit. Verified they
fail against the old behaviour: five go red while every pre-existing test stays
green, which is the point.

requestReboot()'s comment had the semantics backwards - it claimed a negative
delay meant "now" and that rebootAtMsec == 0 was an immediate-reboot sentinel.
Both are inverted: 0 means no reboot pending at every read site, and
admin.proto documents reboot_seconds "<0 to cancel reboot". The expression was a
faithful copy of AdminModule's, so only the comment was wrong, but it was wrong
on the newly-created central helper. The negative branch now says so and logs
it. Five sites still open-coded the deadline despite that helper existing; they
are pure reboots with no config save, so applyConfigChange() does not fit but
requestReboot() does exactly.

SET_SMART_BROADCAST_INTERVAL was the only CONFIG_APPLY_REBOOT site in the InkHUD
menu without a notifyApplyingChanges() beside it - re-adding its reboot last
commit dropped the warning that applyConfigReload() used to raise, so the e-ink
would go dark unannounced. And four channel actions carried CONFIG_APPLY_RADIO
for uplink/downlink and position_precision, none of which touch the name, PSK or
frequency slot the radio derives anything from; they had it only because the old
reloadConfig(SEGMENT_CHANNELS) inferred it from the bitmask.

The doc had gone stale inside its own PR again: it still listed
commit_edit_settings under "intentionally left unchanged". Replaced with a
section on why the bitmask stops protecting you inside a transaction, since that
is the non-obvious part. Also dropped a commit SHA that had already been
orphaned by rebase, exactly as its own note predicted, and a stale plan-doc
reference in the tests that the last doc pass missed.

Full native suite green, 810 cases across 40 suites. t-echo and t-echo-inkhud
both build, covering the menu changes no native env compiles.

* post rebase fixes

* gps-toggle-noreboot

* fix(admin): preserve live config transactions

* fix(admin): avoid unnecessary config restarts

* fix(admin): keep edit timeout dormant while idle

* fix(admin): skip normalized no-op reboots

* fix(admin): disable idle transaction timer

---------

Co-authored-by: nomdetom <nomdetom@protonmail.com>
NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Jul 30, 2026
Four review findings.

broadcast_smart_minimum_interval_secs is not read live, despite the comment this
branch added claiming it was. PositionModule::minimumTimeThreshold is a const
data member initialised when the module is constructed, so the value is captured
at boot; only the sibling distance field is genuinely re-read (PositionModule.cpp
:630). The InkHUD menu therefore applied it with no reboot and it silently did
nothing until the next restart. It now reboots, matching the AdminModule path.
Note the review suggested the opposite fix - adding the field to AdminModule's
live-field list - which would have made the admin path silently ineffective too.

While an edit transaction is open saveChanges() defers the write, which threw
away the per-field reboot and radio decisions; the commit then used the parameter
defaults and always rebooted and reconfigured the radio. Since phone apps write
config through transactions, none of this branch's narrowing reached them. The
deferred decisions now accumulate and the commit honours them.

Two test fixes: the set_ignored_node test relied on an earlier RUN_TEST having
created the node, which breaks under -f or a registration reorder. And the
telemetry test asserted only values it had just assigned - replaced with one that
drives the real toggle and checks the save path sets has_telemetry, the flag
whose absence caused the TAK persistence bug fixed upstream in meshtastic#11216.

clod helped too
NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Jul 30, 2026
* reconfigure tests

* Narrow radio-reload trigger to LoRa-affecting saves only

reloadConfig() inferred "the radio needs reconfiguring" from whether
saveWhat included SEGMENT_CONFIG. But Config is a monolithic segment
(device/position/power/network/display/lora/bluetooth/security share one
file), so every non-LoRa AdminModule save still fired the live SX126x
reconfigure - the same live-SPI-on-the-admin-thread sequence that crashes
on a favorite (meshtastic#11146), just reached via a Bluetooth toggle, WiFi PSK
change, or keypair rotation.

Separate the two concerns: add an explicit radioAffected flag threaded
through AdminModule::saveChanges() into MeshService::reloadConfig(). The
gate becomes `radioAffected && (saveWhat & (SEGMENT_CONFIG|SEGMENT_CHANNELS))`
- the bitmask inference is kept as a backstop and only suppressible, and
radioAffected defaults to true so the ~35 existing reloadConfig() callers
(MenuHandler, MenuApplet, portduino) keep today's behavior untouched.

Only handleSetConfig()'s lora_tag sets radioAffected; every other
sub-message, plus set_fixed_position/remove_fixed_position and position's
nested save, opt out. set_channel/commit_edit_settings/restore keep the
default and still reload.

Add native coverage in test_admin_radio for each non-LoRa sub-message
(asserting no reconfigure), regression guards that lora/set_channel still
reconfigure, and direct reloadConfig() guards pinning the fail-safe default.

* mark up todo sites for checking reloadConfig use

* added clarifying note

* Don't reboot on no-op position/network/bluetooth config sets

handleSetConfig() starts requiresReboot=true and device/power/display each
gate it back down when nothing reboot-worthy changed, but position,
network, and bluetooth never touched requiresReboot - so they rebooted on
*every* set, including a client re-pushing byte-identical config (which
happens routinely on connect/retry).

Add a no-op gate to those three: if the incoming sub-message is
byte-identical to the current one, skip the reboot. A whole-struct memcmp
is the right tool here (unlike the field-by-field device/power/display
gates, which must ignore benign fields) - it answers "did anything change?"
and fails safe: its only error mode is padding-byte differences causing an
unnecessary reboot, never a missed change. All three sub-messages are POD
(no pb_callback_t fields). Any real change still reboots exactly as before.

This is Tier 1 of plan-narrow-reboot-trigger; Tier 2 will further narrow
position to reboot only on boot-only fields (GPIO/GPS).

Adds native coverage asserting rebootAtMsec stays unset on a no-op set and
is armed on a real change, for each of the three sub-messages.

* Apply live position config changes without a reboot

Tier 2 of plan-narrow-reboot-trigger. A position set that touches only
fields the position module consumes live now applies without restarting.

PositionModule reads position_broadcast_secs, position_broadcast_smart_enabled,
broadcast_smart_minimum_distance, position_flags, and fixed_position directly
from config on every send/schedule cycle (fixed_position also has dedicated
live admin handlers) - changing only those needs no reboot. Everything else
stays on the reboot path: GPS driver state (gps_mode/gps_enabled/
gps_update_interval/gps_attempt_time) and GPIO pin assignments (rx_gpio/
tx_gpio/gps_en_gpio), all of which touch subsystem/hardware init.

The live set is deliberately limited to what static analysis proves is
applied live, so this ships without hardware verification; GPS-timing fields
that might also be live were left rebooting (fail toward current behavior).
The gate neutralizes the live fields in a copy and reboots if any other byte
differs, so a future PositionConfig field reboots until explicitly cleared
as live - fail safe for schema growth.

This supersedes the Tier 1 no-op memcmp gate for position (network/bluetooth
keep theirs). Native coverage: a broadcast-interval change does not schedule
a reboot; gps_mode and rx_gpio changes still do.

* docs: config-save radio-reload & reboot gating

Document the AdminModule config-save side-effect work: the radioAffected
and requiresReboot axes, which operations now skip the radio reload or the
reboot, and the operations deliberately left unchanged (commit_edit_settings,
network/bluetooth live-apply, GPS-timing position fields, module config,
and the on-device menu reloadConfig sites) with the reason for each.

* docs: add hardware testing section to config-save gating

The doc only mentioned the outstanding GPS-timing pass in one line and
omitted the radio-reload/crash validation entirely, with no procedure.
Add a Hardware testing section covering: the meshtastic-mcp setup
(BLE connected throughout, nRF52840 SX126x reference board); the
radio-reload/crash regression guard for the favorite-node fix (with a
lora/set_channel positive control); and a per-field procedure with
pass/fail criteria for deciding whether GPS-timing position fields can be
reclassified as live - fail toward rebooting.

* docs: clarify hardware tests run over serial, not menus/BLE

The hardware section implied a BLE connection was required and that the
crash ran on the BLE callback thread. On nRF52 the BLE onWrite only queues;
handleToRadio -> saveChanges -> reloadConfig runs on the main FreeRTOS task,
same context SerialConsole uses. So serial drives the exact code/thread
under test, the on-device menus are unrelated (separate path), and the
original crash was serial-proven. State that serial is sufficient and
correct the thread references accordingly.

* docs: correct crash mechanism to the off-main-thread spiLock lockup

The previous edit, while right that the reconfigure is invoked on the main
task (not the BLE callback thread), oversimplified the failure. The crash
is cross-thread: the main-task setStandby()+SPI reprogram collides with the
radio's off-main NotifiedWorkerThread over the shared non-recursive spiLock
(FreeRTOS binary semaphore), locking it up and tripping the watchdog reboot
(meshtastic#11146; same spiLock hazard as meshtastic#10705/meshtastic#10728). Restore that in both the
Axis 1 rationale and the hardware test-1 description. Does not change the
serial-is-sufficient conclusion (the collision is transport-independent).

* docs: clarify config save behavior for GPS position updates

* menu actions

* menu actions reboot

* Persist telemetry screen toggles from the frame menu

The three moduleConfig.telemetry.*_screen_enabled toggles in the frame-toggle
menu changed the value in memory and never wrote it, so they reverted on any
reboot that did not happen to go through the reboot menu (which saves every
segment). Their sibling entries in the same menu persist via
Screen::toggleFrameVisibility -> saveFrameVisibility, so half the menu was
durable and three entries were not.

These three live in moduleConfig rather than the hiddenFrames blob, so they need
their own SEGMENT_MODULECONFIG save. radioAffected is false: a screen preference
has no business re-initialising the LoRa chip.

clod helped too

* Drop redundant and over-broad config writes from the menus

reloadConfig() ends with an unconditional saveToDisk(saveWhat), outside the
radioAffected guard, so nine sites in the InkHUD menu were writing the same
proto file twice in a row: saveToDisk(X) immediately followed by
reloadConfig(X, ...). One of the nine is the shared applyConfigReload() helper,
so thirteen menu actions were affected.

Five sites in the BaseUI menu called saveUIConfig() after changing a field that
lives in config.proto. saveUIConfig() only writes /prefs/uiconfig.proto, so
those writes could not persist the changed field and did nothing but cost a
flash write. GPSFormatMenu had the mirror image: a uiconfig-only change that
also called reloadConfig(), rewriting config.proto for a field not in it.

Three bare saveToDisk() calls rewrote all five segments to change one bit -
a channel mute flag and two NodeInfoLite bits - so they now pass the segment
they actually touch. NodeInfoLite is written by saveNodeDatabaseToDisk() under
SEGMENT_NODEDATABASE, not SEGMENT_DEVICESTATE. The reboot menu keeps its bare
call: a full flush before a deliberate reboot is correct.

reloadConfig() becomes virtual so tests can count calls; the accompanying test
pins down the saveToDisk equivalence the nine deletions rely on.

clod helped too

* Route config saves through one MeshService::applyConfigChange helper

Applying a config change involves three independent decisions: which proto files
to persist, whether the LoRa chip needs re-initialising, and whether the field
only takes effect after a restart. Those were spread across four helpers with
three different parameter orders, two of which took adjacent bools that compile
fine when transposed. InkHUD's applyConfigReload was the sharp edge: its second
parameter was `reboot`, sitting exactly where reloadConfig and saveChanges take
`radioAffected`.

There is now a single entry point taking a flags enum, so each call site states
its intent and cannot silently mean the opposite. Migrated 23 BaseUI sites, 20
InkHUD sites and the wasm glue. applyConfigReload is deleted and its five
callers inlined; applyLoRaRegion and applyLoRaPreset stay, since they hold real
domain logic beyond bundling, and just delegate.

Adjacent `rebootAtMsec = millis() + ...` assignments fold into the reboot flag.
The one caller that deliberately uses a shorter delay keeps it via the trailing
rebootSeconds parameter. Reboot scheduling itself moves into requestReboot() in
main.cpp, next to the global it sets: previously only AdminModule had a helper
for this, and it was private, which is why every menu open-coded the deadline.
requestReboot carries no UI, because BaseUI already renders the notice at draw
time whenever rebootAtMsec is set.

saveChanges keeps its signature and its edit-transaction deferral, so its
fourteen call sites and the existing gating tests are untouched.

clod helped too

* Extract menu config actions into testable functions

None of the menu save behaviour was reachable from a test: every action lived in
a lambda assigned to BannerOverlayOptions.bannerCallback, which only ever runs
via screen->showOverlayBanner(), so exercising one needed a live Screen. That is
why the defects this series fixes went unnoticed - MenuHandler.cpp compiles in
the native test build, but nothing in it could be called.

Three actions move out into functions that own the whole decision: which segment
to persist, whether the radio needs reconfiguring, and whether to reboot. Chosen
because each covers a defect this series touched - the telemetry toggles that
never persisted, the smart-position reboot that PR meshtastic#11181 removed, and a node-DB
bit write that must not reach the radio.

Deliberately picked extractions that also deduplicate: three telemetry call
sites collapse onto one function and two smart-position sites onto another, so
the promicro image is byte-identical to before. Worth knowing, because that board
has under 1.4 KB of headroom before the warmstore region.

toggleNodeMuted also gains a guard for an unknown node, so a stale
pickedNodeNum can no longer cause a pointless flash write.

clod helped too

* Sort out InkHUD applying-changes notifications

notifyApplyingChanges() was doing two jobs at its call sites in MenuApplet.cpp:
signalling a live change that is applied without restarting, and warning of an
imminent reboot. Audited every site. The finding is that none of them are
redundant, so this records why rather than removing anything.

The two live-change calls - LoRa region and modem preset - cover the seconds the
e-ink takes to redraw while the radio reconfigures, and nothing else raises them.
The reboot-path calls warn before the display goes. requestReboot() cannot absorb
those: it deliberately carries no UI, because BaseUI renders its own notice at
draw time from rebootAtMsec, while e-ink only draws when pushed.

Also worth recording: the existing notifyReboot Observable (sleep.h, fired from
Power::reboot) is a different moment - reboot execution, not scheduling - and
InkHUD already observes it to save settings and shut applets down. A new
"reboot scheduled" observable to centralise these calls would be a third reboot
signal for no functional gain, so it is not added.

clod helped too

* stylee

* Fix stale doc references and document the menu path

Addresses review feedback.

Four comments pointed at plan documents that were never committed
(plan-narrow-reboot-trigger.md, plan-decouple-nodedb-admin-saves.md), so the
rationale they cited was not discoverable. They now point at
docs/admin-config-save-gating.md, which is in-repo.

The doc listed the commit SHAs it described. All five had already been orphaned
by the rebases this branch has been through, exactly as predicted, so it cites
the PR instead. "Status: Implemented" also overstated things while hardware
validation is still outstanding.

The doc's biggest problem was that it had gone out of date within its own PR: it
described the on-device menus as an untouched code path that still reloads the
radio on any Config save, with per-site TODO markers. That stopped being true
when the menus moved onto applyConfigChange. Replaced with a section covering the
new entry point, the flags, why they are a flags enum rather than two bools, and
the saveToDisk equivalence that makes pairing the two calls a double write.

clod helped too

* Fix smart-broadcast-interval reboot and carry transaction flags

Four review findings.

broadcast_smart_minimum_interval_secs is not read live, despite the comment this
branch added claiming it was. PositionModule::minimumTimeThreshold is a const
data member initialised when the module is constructed, so the value is captured
at boot; only the sibling distance field is genuinely re-read (PositionModule.cpp
:630). The InkHUD menu therefore applied it with no reboot and it silently did
nothing until the next restart. It now reboots, matching the AdminModule path.
Note the review suggested the opposite fix - adding the field to AdminModule's
live-field list - which would have made the admin path silently ineffective too.

While an edit transaction is open saveChanges() defers the write, which threw
away the per-field reboot and radio decisions; the commit then used the parameter
defaults and always rebooted and reconfigured the radio. Since phone apps write
config through transactions, none of this branch's narrowing reached them. The
deferred decisions now accumulate and the commit honours them.

Two test fixes: the set_ignored_node test relied on an earlier RUN_TEST having
created the node, which breaks under -f or a registration reorder. And the
telemetry test asserted only values it had just assigned - replaced with one that
drives the real toggle and checks the save path sets has_telemetry, the flag
whose absence caused the TAK persistence bug fixed upstream in meshtastic#11216.

clod helped too

* Stop the edit transaction discarding the per-field save decisions

Review of this branch's own output, plus the two findings Copilot raised on

The headline defect is that none of this branch's narrowing reached phone apps.
saveChanges() defers while an edit transaction is open, and radioAffected
defaulted to true, so eight call sites that never thought about the radio - the
five node-DB handlers, set_owner, set_module_config, and the nested save in the
position case - accumulated a true into deferredRadioAffected. Outside a
transaction that was harmless, because reloadConfig()'s
saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS) bitmask independently blocks a
node-DB-only save from reaching the radio whatever it asks for. Inside one the
commit saves under a fixed full mask, so the bitmask always passes and
radioAffected is the only thing left deciding it. Favouriting a node from the
phone app therefore still ran the live SX126x reconfigure at commit - the

The fix is to remove the default from saveChanges() rather than gate the
deferred flag on the segment mask, which is what the review suggested. Gating
would reinstate exactly the segment-to-radio inference this branch exists to
delete - NodeDB.h now carries a comment telling the next person not to do that -
and it fixes the symptom while leaving the wrong default in place for the next
call site. With no default the compiler makes all fourteen sites state the
answer. The commit also consumes and clears the deferred flags, so a stray
second commit cannot inherit the previous transaction's answer.

That gap existed because every radio test ran outside a transaction, which is
the one arrangement where the bug is unreachable. Nine tests now cover the
deferred path, asserting both axes independently across a commit. Verified they
fail against the old behaviour: five go red while every pre-existing test stays
green, which is the point.

requestReboot()'s comment had the semantics backwards - it claimed a negative
delay meant "now" and that rebootAtMsec == 0 was an immediate-reboot sentinel.
Both are inverted: 0 means no reboot pending at every read site, and
admin.proto documents reboot_seconds "<0 to cancel reboot". The expression was a
faithful copy of AdminModule's, so only the comment was wrong, but it was wrong
on the newly-created central helper. The negative branch now says so and logs
it. Five sites still open-coded the deadline despite that helper existing; they
are pure reboots with no config save, so applyConfigChange() does not fit but
requestReboot() does exactly.

SET_SMART_BROADCAST_INTERVAL was the only CONFIG_APPLY_REBOOT site in the InkHUD
menu without a notifyApplyingChanges() beside it - re-adding its reboot last
commit dropped the warning that applyConfigReload() used to raise, so the e-ink
would go dark unannounced. And four channel actions carried CONFIG_APPLY_RADIO
for uplink/downlink and position_precision, none of which touch the name, PSK or
frequency slot the radio derives anything from; they had it only because the old
reloadConfig(SEGMENT_CHANNELS) inferred it from the bitmask.

The doc had gone stale inside its own PR again: it still listed
commit_edit_settings under "intentionally left unchanged". Replaced with a
section on why the bitmask stops protecting you inside a transaction, since that
is the non-obvious part. Also dropped a commit SHA that had already been
orphaned by rebase, exactly as its own note predicted, and a stale plan-doc
reference in the tests that the last doc pass missed.

Full native suite green, 810 cases across 40 suites. t-echo and t-echo-inkhud
both build, covering the menu changes no native env compiles.

* post rebase fixes

* gps-toggle-noreboot

* fix(admin): preserve live config transactions

* fix(admin): avoid unnecessary config restarts

* fix(admin): keep edit timeout dormant while idle

* fix(admin): skip normalized no-op reboots

* fix(admin): disable idle transaction timer

---------

Co-authored-by: nomdetom <nomdetom@protonmail.com>
NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Aug 1, 2026
Four review findings.

broadcast_smart_minimum_interval_secs is not read live, despite the comment this
branch added claiming it was. PositionModule::minimumTimeThreshold is a const
data member initialised when the module is constructed, so the value is captured
at boot; only the sibling distance field is genuinely re-read (PositionModule.cpp
:630). The InkHUD menu therefore applied it with no reboot and it silently did
nothing until the next restart. It now reboots, matching the AdminModule path.
Note the review suggested the opposite fix - adding the field to AdminModule's
live-field list - which would have made the admin path silently ineffective too.

While an edit transaction is open saveChanges() defers the write, which threw
away the per-field reboot and radio decisions; the commit then used the parameter
defaults and always rebooted and reconfigured the radio. Since phone apps write
config through transactions, none of this branch's narrowing reached them. The
deferred decisions now accumulate and the commit honours them.

Two test fixes: the set_ignored_node test relied on an earlier RUN_TEST having
created the node, which breaks under -f or a registration reorder. And the
telemetry test asserted only values it had just assigned - replaced with one that
drives the real toggle and checks the save path sets has_telemetry, the flag
whose absence caused the TAK persistence bug fixed upstream in meshtastic#11216.

clod helped too
NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Aug 1, 2026
* reconfigure tests

* Narrow radio-reload trigger to LoRa-affecting saves only

reloadConfig() inferred "the radio needs reconfiguring" from whether
saveWhat included SEGMENT_CONFIG. But Config is a monolithic segment
(device/position/power/network/display/lora/bluetooth/security share one
file), so every non-LoRa AdminModule save still fired the live SX126x
reconfigure - the same live-SPI-on-the-admin-thread sequence that crashes
on a favorite (meshtastic#11146), just reached via a Bluetooth toggle, WiFi PSK
change, or keypair rotation.

Separate the two concerns: add an explicit radioAffected flag threaded
through AdminModule::saveChanges() into MeshService::reloadConfig(). The
gate becomes `radioAffected && (saveWhat & (SEGMENT_CONFIG|SEGMENT_CHANNELS))`
- the bitmask inference is kept as a backstop and only suppressible, and
radioAffected defaults to true so the ~35 existing reloadConfig() callers
(MenuHandler, MenuApplet, portduino) keep today's behavior untouched.

Only handleSetConfig()'s lora_tag sets radioAffected; every other
sub-message, plus set_fixed_position/remove_fixed_position and position's
nested save, opt out. set_channel/commit_edit_settings/restore keep the
default and still reload.

Add native coverage in test_admin_radio for each non-LoRa sub-message
(asserting no reconfigure), regression guards that lora/set_channel still
reconfigure, and direct reloadConfig() guards pinning the fail-safe default.

* mark up todo sites for checking reloadConfig use

* added clarifying note

* Don't reboot on no-op position/network/bluetooth config sets

handleSetConfig() starts requiresReboot=true and device/power/display each
gate it back down when nothing reboot-worthy changed, but position,
network, and bluetooth never touched requiresReboot - so they rebooted on
*every* set, including a client re-pushing byte-identical config (which
happens routinely on connect/retry).

Add a no-op gate to those three: if the incoming sub-message is
byte-identical to the current one, skip the reboot. A whole-struct memcmp
is the right tool here (unlike the field-by-field device/power/display
gates, which must ignore benign fields) - it answers "did anything change?"
and fails safe: its only error mode is padding-byte differences causing an
unnecessary reboot, never a missed change. All three sub-messages are POD
(no pb_callback_t fields). Any real change still reboots exactly as before.

This is Tier 1 of plan-narrow-reboot-trigger; Tier 2 will further narrow
position to reboot only on boot-only fields (GPIO/GPS).

Adds native coverage asserting rebootAtMsec stays unset on a no-op set and
is armed on a real change, for each of the three sub-messages.

* Apply live position config changes without a reboot

Tier 2 of plan-narrow-reboot-trigger. A position set that touches only
fields the position module consumes live now applies without restarting.

PositionModule reads position_broadcast_secs, position_broadcast_smart_enabled,
broadcast_smart_minimum_distance, position_flags, and fixed_position directly
from config on every send/schedule cycle (fixed_position also has dedicated
live admin handlers) - changing only those needs no reboot. Everything else
stays on the reboot path: GPS driver state (gps_mode/gps_enabled/
gps_update_interval/gps_attempt_time) and GPIO pin assignments (rx_gpio/
tx_gpio/gps_en_gpio), all of which touch subsystem/hardware init.

The live set is deliberately limited to what static analysis proves is
applied live, so this ships without hardware verification; GPS-timing fields
that might also be live were left rebooting (fail toward current behavior).
The gate neutralizes the live fields in a copy and reboots if any other byte
differs, so a future PositionConfig field reboots until explicitly cleared
as live - fail safe for schema growth.

This supersedes the Tier 1 no-op memcmp gate for position (network/bluetooth
keep theirs). Native coverage: a broadcast-interval change does not schedule
a reboot; gps_mode and rx_gpio changes still do.

* docs: config-save radio-reload & reboot gating

Document the AdminModule config-save side-effect work: the radioAffected
and requiresReboot axes, which operations now skip the radio reload or the
reboot, and the operations deliberately left unchanged (commit_edit_settings,
network/bluetooth live-apply, GPS-timing position fields, module config,
and the on-device menu reloadConfig sites) with the reason for each.

* docs: add hardware testing section to config-save gating

The doc only mentioned the outstanding GPS-timing pass in one line and
omitted the radio-reload/crash validation entirely, with no procedure.
Add a Hardware testing section covering: the meshtastic-mcp setup
(BLE connected throughout, nRF52840 SX126x reference board); the
radio-reload/crash regression guard for the favorite-node fix (with a
lora/set_channel positive control); and a per-field procedure with
pass/fail criteria for deciding whether GPS-timing position fields can be
reclassified as live - fail toward rebooting.

* docs: clarify hardware tests run over serial, not menus/BLE

The hardware section implied a BLE connection was required and that the
crash ran on the BLE callback thread. On nRF52 the BLE onWrite only queues;
handleToRadio -> saveChanges -> reloadConfig runs on the main FreeRTOS task,
same context SerialConsole uses. So serial drives the exact code/thread
under test, the on-device menus are unrelated (separate path), and the
original crash was serial-proven. State that serial is sufficient and
correct the thread references accordingly.

* docs: correct crash mechanism to the off-main-thread spiLock lockup

The previous edit, while right that the reconfigure is invoked on the main
task (not the BLE callback thread), oversimplified the failure. The crash
is cross-thread: the main-task setStandby()+SPI reprogram collides with the
radio's off-main NotifiedWorkerThread over the shared non-recursive spiLock
(FreeRTOS binary semaphore), locking it up and tripping the watchdog reboot
(meshtastic#11146; same spiLock hazard as meshtastic#10705/meshtastic#10728). Restore that in both the
Axis 1 rationale and the hardware test-1 description. Does not change the
serial-is-sufficient conclusion (the collision is transport-independent).

* docs: clarify config save behavior for GPS position updates

* menu actions

* menu actions reboot

* Persist telemetry screen toggles from the frame menu

The three moduleConfig.telemetry.*_screen_enabled toggles in the frame-toggle
menu changed the value in memory and never wrote it, so they reverted on any
reboot that did not happen to go through the reboot menu (which saves every
segment). Their sibling entries in the same menu persist via
Screen::toggleFrameVisibility -> saveFrameVisibility, so half the menu was
durable and three entries were not.

These three live in moduleConfig rather than the hiddenFrames blob, so they need
their own SEGMENT_MODULECONFIG save. radioAffected is false: a screen preference
has no business re-initialising the LoRa chip.

clod helped too

* Drop redundant and over-broad config writes from the menus

reloadConfig() ends with an unconditional saveToDisk(saveWhat), outside the
radioAffected guard, so nine sites in the InkHUD menu were writing the same
proto file twice in a row: saveToDisk(X) immediately followed by
reloadConfig(X, ...). One of the nine is the shared applyConfigReload() helper,
so thirteen menu actions were affected.

Five sites in the BaseUI menu called saveUIConfig() after changing a field that
lives in config.proto. saveUIConfig() only writes /prefs/uiconfig.proto, so
those writes could not persist the changed field and did nothing but cost a
flash write. GPSFormatMenu had the mirror image: a uiconfig-only change that
also called reloadConfig(), rewriting config.proto for a field not in it.

Three bare saveToDisk() calls rewrote all five segments to change one bit -
a channel mute flag and two NodeInfoLite bits - so they now pass the segment
they actually touch. NodeInfoLite is written by saveNodeDatabaseToDisk() under
SEGMENT_NODEDATABASE, not SEGMENT_DEVICESTATE. The reboot menu keeps its bare
call: a full flush before a deliberate reboot is correct.

reloadConfig() becomes virtual so tests can count calls; the accompanying test
pins down the saveToDisk equivalence the nine deletions rely on.

clod helped too

* Route config saves through one MeshService::applyConfigChange helper

Applying a config change involves three independent decisions: which proto files
to persist, whether the LoRa chip needs re-initialising, and whether the field
only takes effect after a restart. Those were spread across four helpers with
three different parameter orders, two of which took adjacent bools that compile
fine when transposed. InkHUD's applyConfigReload was the sharp edge: its second
parameter was `reboot`, sitting exactly where reloadConfig and saveChanges take
`radioAffected`.

There is now a single entry point taking a flags enum, so each call site states
its intent and cannot silently mean the opposite. Migrated 23 BaseUI sites, 20
InkHUD sites and the wasm glue. applyConfigReload is deleted and its five
callers inlined; applyLoRaRegion and applyLoRaPreset stay, since they hold real
domain logic beyond bundling, and just delegate.

Adjacent `rebootAtMsec = millis() + ...` assignments fold into the reboot flag.
The one caller that deliberately uses a shorter delay keeps it via the trailing
rebootSeconds parameter. Reboot scheduling itself moves into requestReboot() in
main.cpp, next to the global it sets: previously only AdminModule had a helper
for this, and it was private, which is why every menu open-coded the deadline.
requestReboot carries no UI, because BaseUI already renders the notice at draw
time whenever rebootAtMsec is set.

saveChanges keeps its signature and its edit-transaction deferral, so its
fourteen call sites and the existing gating tests are untouched.

clod helped too

* Extract menu config actions into testable functions

None of the menu save behaviour was reachable from a test: every action lived in
a lambda assigned to BannerOverlayOptions.bannerCallback, which only ever runs
via screen->showOverlayBanner(), so exercising one needed a live Screen. That is
why the defects this series fixes went unnoticed - MenuHandler.cpp compiles in
the native test build, but nothing in it could be called.

Three actions move out into functions that own the whole decision: which segment
to persist, whether the radio needs reconfiguring, and whether to reboot. Chosen
because each covers a defect this series touched - the telemetry toggles that
never persisted, the smart-position reboot that PR meshtastic#11181 removed, and a node-DB
bit write that must not reach the radio.

Deliberately picked extractions that also deduplicate: three telemetry call
sites collapse onto one function and two smart-position sites onto another, so
the promicro image is byte-identical to before. Worth knowing, because that board
has under 1.4 KB of headroom before the warmstore region.

toggleNodeMuted also gains a guard for an unknown node, so a stale
pickedNodeNum can no longer cause a pointless flash write.

clod helped too

* Sort out InkHUD applying-changes notifications

notifyApplyingChanges() was doing two jobs at its call sites in MenuApplet.cpp:
signalling a live change that is applied without restarting, and warning of an
imminent reboot. Audited every site. The finding is that none of them are
redundant, so this records why rather than removing anything.

The two live-change calls - LoRa region and modem preset - cover the seconds the
e-ink takes to redraw while the radio reconfigures, and nothing else raises them.
The reboot-path calls warn before the display goes. requestReboot() cannot absorb
those: it deliberately carries no UI, because BaseUI renders its own notice at
draw time from rebootAtMsec, while e-ink only draws when pushed.

Also worth recording: the existing notifyReboot Observable (sleep.h, fired from
Power::reboot) is a different moment - reboot execution, not scheduling - and
InkHUD already observes it to save settings and shut applets down. A new
"reboot scheduled" observable to centralise these calls would be a third reboot
signal for no functional gain, so it is not added.

clod helped too

* stylee

* Fix stale doc references and document the menu path

Addresses review feedback.

Four comments pointed at plan documents that were never committed
(plan-narrow-reboot-trigger.md, plan-decouple-nodedb-admin-saves.md), so the
rationale they cited was not discoverable. They now point at
docs/admin-config-save-gating.md, which is in-repo.

The doc listed the commit SHAs it described. All five had already been orphaned
by the rebases this branch has been through, exactly as predicted, so it cites
the PR instead. "Status: Implemented" also overstated things while hardware
validation is still outstanding.

The doc's biggest problem was that it had gone out of date within its own PR: it
described the on-device menus as an untouched code path that still reloads the
radio on any Config save, with per-site TODO markers. That stopped being true
when the menus moved onto applyConfigChange. Replaced with a section covering the
new entry point, the flags, why they are a flags enum rather than two bools, and
the saveToDisk equivalence that makes pairing the two calls a double write.

clod helped too

* Fix smart-broadcast-interval reboot and carry transaction flags

Four review findings.

broadcast_smart_minimum_interval_secs is not read live, despite the comment this
branch added claiming it was. PositionModule::minimumTimeThreshold is a const
data member initialised when the module is constructed, so the value is captured
at boot; only the sibling distance field is genuinely re-read (PositionModule.cpp
:630). The InkHUD menu therefore applied it with no reboot and it silently did
nothing until the next restart. It now reboots, matching the AdminModule path.
Note the review suggested the opposite fix - adding the field to AdminModule's
live-field list - which would have made the admin path silently ineffective too.

While an edit transaction is open saveChanges() defers the write, which threw
away the per-field reboot and radio decisions; the commit then used the parameter
defaults and always rebooted and reconfigured the radio. Since phone apps write
config through transactions, none of this branch's narrowing reached them. The
deferred decisions now accumulate and the commit honours them.

Two test fixes: the set_ignored_node test relied on an earlier RUN_TEST having
created the node, which breaks under -f or a registration reorder. And the
telemetry test asserted only values it had just assigned - replaced with one that
drives the real toggle and checks the save path sets has_telemetry, the flag
whose absence caused the TAK persistence bug fixed upstream in meshtastic#11216.

clod helped too

* Stop the edit transaction discarding the per-field save decisions

Review of this branch's own output, plus the two findings Copilot raised on

The headline defect is that none of this branch's narrowing reached phone apps.
saveChanges() defers while an edit transaction is open, and radioAffected
defaulted to true, so eight call sites that never thought about the radio - the
five node-DB handlers, set_owner, set_module_config, and the nested save in the
position case - accumulated a true into deferredRadioAffected. Outside a
transaction that was harmless, because reloadConfig()'s
saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS) bitmask independently blocks a
node-DB-only save from reaching the radio whatever it asks for. Inside one the
commit saves under a fixed full mask, so the bitmask always passes and
radioAffected is the only thing left deciding it. Favouriting a node from the
phone app therefore still ran the live SX126x reconfigure at commit - the

The fix is to remove the default from saveChanges() rather than gate the
deferred flag on the segment mask, which is what the review suggested. Gating
would reinstate exactly the segment-to-radio inference this branch exists to
delete - NodeDB.h now carries a comment telling the next person not to do that -
and it fixes the symptom while leaving the wrong default in place for the next
call site. With no default the compiler makes all fourteen sites state the
answer. The commit also consumes and clears the deferred flags, so a stray
second commit cannot inherit the previous transaction's answer.

That gap existed because every radio test ran outside a transaction, which is
the one arrangement where the bug is unreachable. Nine tests now cover the
deferred path, asserting both axes independently across a commit. Verified they
fail against the old behaviour: five go red while every pre-existing test stays
green, which is the point.

requestReboot()'s comment had the semantics backwards - it claimed a negative
delay meant "now" and that rebootAtMsec == 0 was an immediate-reboot sentinel.
Both are inverted: 0 means no reboot pending at every read site, and
admin.proto documents reboot_seconds "<0 to cancel reboot". The expression was a
faithful copy of AdminModule's, so only the comment was wrong, but it was wrong
on the newly-created central helper. The negative branch now says so and logs
it. Five sites still open-coded the deadline despite that helper existing; they
are pure reboots with no config save, so applyConfigChange() does not fit but
requestReboot() does exactly.

SET_SMART_BROADCAST_INTERVAL was the only CONFIG_APPLY_REBOOT site in the InkHUD
menu without a notifyApplyingChanges() beside it - re-adding its reboot last
commit dropped the warning that applyConfigReload() used to raise, so the e-ink
would go dark unannounced. And four channel actions carried CONFIG_APPLY_RADIO
for uplink/downlink and position_precision, none of which touch the name, PSK or
frequency slot the radio derives anything from; they had it only because the old
reloadConfig(SEGMENT_CHANNELS) inferred it from the bitmask.

The doc had gone stale inside its own PR again: it still listed
commit_edit_settings under "intentionally left unchanged". Replaced with a
section on why the bitmask stops protecting you inside a transaction, since that
is the non-obvious part. Also dropped a commit SHA that had already been
orphaned by rebase, exactly as its own note predicted, and a stale plan-doc
reference in the tests that the last doc pass missed.

Full native suite green, 810 cases across 40 suites. t-echo and t-echo-inkhud
both build, covering the menu changes no native env compiles.

* post rebase fixes

* gps-toggle-noreboot

* fix(admin): preserve live config transactions

* fix(admin): avoid unnecessary config restarts

* fix(admin): keep edit timeout dormant while idle

* fix(admin): skip normalized no-op reboots

* fix(admin): disable idle transaction timer

---------

Co-authored-by: nomdetom <nomdetom@protonmail.com>
NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Aug 2, 2026
Four review findings.

broadcast_smart_minimum_interval_secs is not read live, despite the comment this
branch added claiming it was. PositionModule::minimumTimeThreshold is a const
data member initialised when the module is constructed, so the value is captured
at boot; only the sibling distance field is genuinely re-read (PositionModule.cpp
:630). The InkHUD menu therefore applied it with no reboot and it silently did
nothing until the next restart. It now reboots, matching the AdminModule path.
Note the review suggested the opposite fix - adding the field to AdminModule's
live-field list - which would have made the admin path silently ineffective too.

While an edit transaction is open saveChanges() defers the write, which threw
away the per-field reboot and radio decisions; the commit then used the parameter
defaults and always rebooted and reconfigured the radio. Since phone apps write
config through transactions, none of this branch's narrowing reached them. The
deferred decisions now accumulate and the commit honours them.

Two test fixes: the set_ignored_node test relied on an earlier RUN_TEST having
created the node, which breaks under -f or a registration reorder. And the
telemetry test asserted only values it had just assigned - replaced with one that
drives the real toggle and checks the save path sets has_telemetry, the flag
whose absence caused the TAK persistence bug fixed upstream in meshtastic#11216.

clod helped too
NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Aug 2, 2026
* reconfigure tests

* Narrow radio-reload trigger to LoRa-affecting saves only

reloadConfig() inferred "the radio needs reconfiguring" from whether
saveWhat included SEGMENT_CONFIG. But Config is a monolithic segment
(device/position/power/network/display/lora/bluetooth/security share one
file), so every non-LoRa AdminModule save still fired the live SX126x
reconfigure - the same live-SPI-on-the-admin-thread sequence that crashes
on a favorite (meshtastic#11146), just reached via a Bluetooth toggle, WiFi PSK
change, or keypair rotation.

Separate the two concerns: add an explicit radioAffected flag threaded
through AdminModule::saveChanges() into MeshService::reloadConfig(). The
gate becomes `radioAffected && (saveWhat & (SEGMENT_CONFIG|SEGMENT_CHANNELS))`
- the bitmask inference is kept as a backstop and only suppressible, and
radioAffected defaults to true so the ~35 existing reloadConfig() callers
(MenuHandler, MenuApplet, portduino) keep today's behavior untouched.

Only handleSetConfig()'s lora_tag sets radioAffected; every other
sub-message, plus set_fixed_position/remove_fixed_position and position's
nested save, opt out. set_channel/commit_edit_settings/restore keep the
default and still reload.

Add native coverage in test_admin_radio for each non-LoRa sub-message
(asserting no reconfigure), regression guards that lora/set_channel still
reconfigure, and direct reloadConfig() guards pinning the fail-safe default.

* mark up todo sites for checking reloadConfig use

* added clarifying note

* Don't reboot on no-op position/network/bluetooth config sets

handleSetConfig() starts requiresReboot=true and device/power/display each
gate it back down when nothing reboot-worthy changed, but position,
network, and bluetooth never touched requiresReboot - so they rebooted on
*every* set, including a client re-pushing byte-identical config (which
happens routinely on connect/retry).

Add a no-op gate to those three: if the incoming sub-message is
byte-identical to the current one, skip the reboot. A whole-struct memcmp
is the right tool here (unlike the field-by-field device/power/display
gates, which must ignore benign fields) - it answers "did anything change?"
and fails safe: its only error mode is padding-byte differences causing an
unnecessary reboot, never a missed change. All three sub-messages are POD
(no pb_callback_t fields). Any real change still reboots exactly as before.

This is Tier 1 of plan-narrow-reboot-trigger; Tier 2 will further narrow
position to reboot only on boot-only fields (GPIO/GPS).

Adds native coverage asserting rebootAtMsec stays unset on a no-op set and
is armed on a real change, for each of the three sub-messages.

* Apply live position config changes without a reboot

Tier 2 of plan-narrow-reboot-trigger. A position set that touches only
fields the position module consumes live now applies without restarting.

PositionModule reads position_broadcast_secs, position_broadcast_smart_enabled,
broadcast_smart_minimum_distance, position_flags, and fixed_position directly
from config on every send/schedule cycle (fixed_position also has dedicated
live admin handlers) - changing only those needs no reboot. Everything else
stays on the reboot path: GPS driver state (gps_mode/gps_enabled/
gps_update_interval/gps_attempt_time) and GPIO pin assignments (rx_gpio/
tx_gpio/gps_en_gpio), all of which touch subsystem/hardware init.

The live set is deliberately limited to what static analysis proves is
applied live, so this ships without hardware verification; GPS-timing fields
that might also be live were left rebooting (fail toward current behavior).
The gate neutralizes the live fields in a copy and reboots if any other byte
differs, so a future PositionConfig field reboots until explicitly cleared
as live - fail safe for schema growth.

This supersedes the Tier 1 no-op memcmp gate for position (network/bluetooth
keep theirs). Native coverage: a broadcast-interval change does not schedule
a reboot; gps_mode and rx_gpio changes still do.

* docs: config-save radio-reload & reboot gating

Document the AdminModule config-save side-effect work: the radioAffected
and requiresReboot axes, which operations now skip the radio reload or the
reboot, and the operations deliberately left unchanged (commit_edit_settings,
network/bluetooth live-apply, GPS-timing position fields, module config,
and the on-device menu reloadConfig sites) with the reason for each.

* docs: add hardware testing section to config-save gating

The doc only mentioned the outstanding GPS-timing pass in one line and
omitted the radio-reload/crash validation entirely, with no procedure.
Add a Hardware testing section covering: the meshtastic-mcp setup
(BLE connected throughout, nRF52840 SX126x reference board); the
radio-reload/crash regression guard for the favorite-node fix (with a
lora/set_channel positive control); and a per-field procedure with
pass/fail criteria for deciding whether GPS-timing position fields can be
reclassified as live - fail toward rebooting.

* docs: clarify hardware tests run over serial, not menus/BLE

The hardware section implied a BLE connection was required and that the
crash ran on the BLE callback thread. On nRF52 the BLE onWrite only queues;
handleToRadio -> saveChanges -> reloadConfig runs on the main FreeRTOS task,
same context SerialConsole uses. So serial drives the exact code/thread
under test, the on-device menus are unrelated (separate path), and the
original crash was serial-proven. State that serial is sufficient and
correct the thread references accordingly.

* docs: correct crash mechanism to the off-main-thread spiLock lockup

The previous edit, while right that the reconfigure is invoked on the main
task (not the BLE callback thread), oversimplified the failure. The crash
is cross-thread: the main-task setStandby()+SPI reprogram collides with the
radio's off-main NotifiedWorkerThread over the shared non-recursive spiLock
(FreeRTOS binary semaphore), locking it up and tripping the watchdog reboot
(meshtastic#11146; same spiLock hazard as meshtastic#10705/meshtastic#10728). Restore that in both the
Axis 1 rationale and the hardware test-1 description. Does not change the
serial-is-sufficient conclusion (the collision is transport-independent).

* docs: clarify config save behavior for GPS position updates

* menu actions

* menu actions reboot

* Persist telemetry screen toggles from the frame menu

The three moduleConfig.telemetry.*_screen_enabled toggles in the frame-toggle
menu changed the value in memory and never wrote it, so they reverted on any
reboot that did not happen to go through the reboot menu (which saves every
segment). Their sibling entries in the same menu persist via
Screen::toggleFrameVisibility -> saveFrameVisibility, so half the menu was
durable and three entries were not.

These three live in moduleConfig rather than the hiddenFrames blob, so they need
their own SEGMENT_MODULECONFIG save. radioAffected is false: a screen preference
has no business re-initialising the LoRa chip.

clod helped too

* Drop redundant and over-broad config writes from the menus

reloadConfig() ends with an unconditional saveToDisk(saveWhat), outside the
radioAffected guard, so nine sites in the InkHUD menu were writing the same
proto file twice in a row: saveToDisk(X) immediately followed by
reloadConfig(X, ...). One of the nine is the shared applyConfigReload() helper,
so thirteen menu actions were affected.

Five sites in the BaseUI menu called saveUIConfig() after changing a field that
lives in config.proto. saveUIConfig() only writes /prefs/uiconfig.proto, so
those writes could not persist the changed field and did nothing but cost a
flash write. GPSFormatMenu had the mirror image: a uiconfig-only change that
also called reloadConfig(), rewriting config.proto for a field not in it.

Three bare saveToDisk() calls rewrote all five segments to change one bit -
a channel mute flag and two NodeInfoLite bits - so they now pass the segment
they actually touch. NodeInfoLite is written by saveNodeDatabaseToDisk() under
SEGMENT_NODEDATABASE, not SEGMENT_DEVICESTATE. The reboot menu keeps its bare
call: a full flush before a deliberate reboot is correct.

reloadConfig() becomes virtual so tests can count calls; the accompanying test
pins down the saveToDisk equivalence the nine deletions rely on.

clod helped too

* Route config saves through one MeshService::applyConfigChange helper

Applying a config change involves three independent decisions: which proto files
to persist, whether the LoRa chip needs re-initialising, and whether the field
only takes effect after a restart. Those were spread across four helpers with
three different parameter orders, two of which took adjacent bools that compile
fine when transposed. InkHUD's applyConfigReload was the sharp edge: its second
parameter was `reboot`, sitting exactly where reloadConfig and saveChanges take
`radioAffected`.

There is now a single entry point taking a flags enum, so each call site states
its intent and cannot silently mean the opposite. Migrated 23 BaseUI sites, 20
InkHUD sites and the wasm glue. applyConfigReload is deleted and its five
callers inlined; applyLoRaRegion and applyLoRaPreset stay, since they hold real
domain logic beyond bundling, and just delegate.

Adjacent `rebootAtMsec = millis() + ...` assignments fold into the reboot flag.
The one caller that deliberately uses a shorter delay keeps it via the trailing
rebootSeconds parameter. Reboot scheduling itself moves into requestReboot() in
main.cpp, next to the global it sets: previously only AdminModule had a helper
for this, and it was private, which is why every menu open-coded the deadline.
requestReboot carries no UI, because BaseUI already renders the notice at draw
time whenever rebootAtMsec is set.

saveChanges keeps its signature and its edit-transaction deferral, so its
fourteen call sites and the existing gating tests are untouched.

clod helped too

* Extract menu config actions into testable functions

None of the menu save behaviour was reachable from a test: every action lived in
a lambda assigned to BannerOverlayOptions.bannerCallback, which only ever runs
via screen->showOverlayBanner(), so exercising one needed a live Screen. That is
why the defects this series fixes went unnoticed - MenuHandler.cpp compiles in
the native test build, but nothing in it could be called.

Three actions move out into functions that own the whole decision: which segment
to persist, whether the radio needs reconfiguring, and whether to reboot. Chosen
because each covers a defect this series touched - the telemetry toggles that
never persisted, the smart-position reboot that PR meshtastic#11181 removed, and a node-DB
bit write that must not reach the radio.

Deliberately picked extractions that also deduplicate: three telemetry call
sites collapse onto one function and two smart-position sites onto another, so
the promicro image is byte-identical to before. Worth knowing, because that board
has under 1.4 KB of headroom before the warmstore region.

toggleNodeMuted also gains a guard for an unknown node, so a stale
pickedNodeNum can no longer cause a pointless flash write.

clod helped too

* Sort out InkHUD applying-changes notifications

notifyApplyingChanges() was doing two jobs at its call sites in MenuApplet.cpp:
signalling a live change that is applied without restarting, and warning of an
imminent reboot. Audited every site. The finding is that none of them are
redundant, so this records why rather than removing anything.

The two live-change calls - LoRa region and modem preset - cover the seconds the
e-ink takes to redraw while the radio reconfigures, and nothing else raises them.
The reboot-path calls warn before the display goes. requestReboot() cannot absorb
those: it deliberately carries no UI, because BaseUI renders its own notice at
draw time from rebootAtMsec, while e-ink only draws when pushed.

Also worth recording: the existing notifyReboot Observable (sleep.h, fired from
Power::reboot) is a different moment - reboot execution, not scheduling - and
InkHUD already observes it to save settings and shut applets down. A new
"reboot scheduled" observable to centralise these calls would be a third reboot
signal for no functional gain, so it is not added.

clod helped too

* stylee

* Fix stale doc references and document the menu path

Addresses review feedback.

Four comments pointed at plan documents that were never committed
(plan-narrow-reboot-trigger.md, plan-decouple-nodedb-admin-saves.md), so the
rationale they cited was not discoverable. They now point at
docs/admin-config-save-gating.md, which is in-repo.

The doc listed the commit SHAs it described. All five had already been orphaned
by the rebases this branch has been through, exactly as predicted, so it cites
the PR instead. "Status: Implemented" also overstated things while hardware
validation is still outstanding.

The doc's biggest problem was that it had gone out of date within its own PR: it
described the on-device menus as an untouched code path that still reloads the
radio on any Config save, with per-site TODO markers. That stopped being true
when the menus moved onto applyConfigChange. Replaced with a section covering the
new entry point, the flags, why they are a flags enum rather than two bools, and
the saveToDisk equivalence that makes pairing the two calls a double write.

clod helped too

* Fix smart-broadcast-interval reboot and carry transaction flags

Four review findings.

broadcast_smart_minimum_interval_secs is not read live, despite the comment this
branch added claiming it was. PositionModule::minimumTimeThreshold is a const
data member initialised when the module is constructed, so the value is captured
at boot; only the sibling distance field is genuinely re-read (PositionModule.cpp
:630). The InkHUD menu therefore applied it with no reboot and it silently did
nothing until the next restart. It now reboots, matching the AdminModule path.
Note the review suggested the opposite fix - adding the field to AdminModule's
live-field list - which would have made the admin path silently ineffective too.

While an edit transaction is open saveChanges() defers the write, which threw
away the per-field reboot and radio decisions; the commit then used the parameter
defaults and always rebooted and reconfigured the radio. Since phone apps write
config through transactions, none of this branch's narrowing reached them. The
deferred decisions now accumulate and the commit honours them.

Two test fixes: the set_ignored_node test relied on an earlier RUN_TEST having
created the node, which breaks under -f or a registration reorder. And the
telemetry test asserted only values it had just assigned - replaced with one that
drives the real toggle and checks the save path sets has_telemetry, the flag
whose absence caused the TAK persistence bug fixed upstream in meshtastic#11216.

clod helped too

* Stop the edit transaction discarding the per-field save decisions

Review of this branch's own output, plus the two findings Copilot raised on

The headline defect is that none of this branch's narrowing reached phone apps.
saveChanges() defers while an edit transaction is open, and radioAffected
defaulted to true, so eight call sites that never thought about the radio - the
five node-DB handlers, set_owner, set_module_config, and the nested save in the
position case - accumulated a true into deferredRadioAffected. Outside a
transaction that was harmless, because reloadConfig()'s
saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS) bitmask independently blocks a
node-DB-only save from reaching the radio whatever it asks for. Inside one the
commit saves under a fixed full mask, so the bitmask always passes and
radioAffected is the only thing left deciding it. Favouriting a node from the
phone app therefore still ran the live SX126x reconfigure at commit - the

The fix is to remove the default from saveChanges() rather than gate the
deferred flag on the segment mask, which is what the review suggested. Gating
would reinstate exactly the segment-to-radio inference this branch exists to
delete - NodeDB.h now carries a comment telling the next person not to do that -
and it fixes the symptom while leaving the wrong default in place for the next
call site. With no default the compiler makes all fourteen sites state the
answer. The commit also consumes and clears the deferred flags, so a stray
second commit cannot inherit the previous transaction's answer.

That gap existed because every radio test ran outside a transaction, which is
the one arrangement where the bug is unreachable. Nine tests now cover the
deferred path, asserting both axes independently across a commit. Verified they
fail against the old behaviour: five go red while every pre-existing test stays
green, which is the point.

requestReboot()'s comment had the semantics backwards - it claimed a negative
delay meant "now" and that rebootAtMsec == 0 was an immediate-reboot sentinel.
Both are inverted: 0 means no reboot pending at every read site, and
admin.proto documents reboot_seconds "<0 to cancel reboot". The expression was a
faithful copy of AdminModule's, so only the comment was wrong, but it was wrong
on the newly-created central helper. The negative branch now says so and logs
it. Five sites still open-coded the deadline despite that helper existing; they
are pure reboots with no config save, so applyConfigChange() does not fit but
requestReboot() does exactly.

SET_SMART_BROADCAST_INTERVAL was the only CONFIG_APPLY_REBOOT site in the InkHUD
menu without a notifyApplyingChanges() beside it - re-adding its reboot last
commit dropped the warning that applyConfigReload() used to raise, so the e-ink
would go dark unannounced. And four channel actions carried CONFIG_APPLY_RADIO
for uplink/downlink and position_precision, none of which touch the name, PSK or
frequency slot the radio derives anything from; they had it only because the old
reloadConfig(SEGMENT_CHANNELS) inferred it from the bitmask.

The doc had gone stale inside its own PR again: it still listed
commit_edit_settings under "intentionally left unchanged". Replaced with a
section on why the bitmask stops protecting you inside a transaction, since that
is the non-obvious part. Also dropped a commit SHA that had already been
orphaned by rebase, exactly as its own note predicted, and a stale plan-doc
reference in the tests that the last doc pass missed.

Full native suite green, 810 cases across 40 suites. t-echo and t-echo-inkhud
both build, covering the menu changes no native env compiles.

* post rebase fixes

* gps-toggle-noreboot

* fix(admin): preserve live config transactions

* fix(admin): avoid unnecessary config restarts

* fix(admin): keep edit timeout dormant while idle

* fix(admin): skip normalized no-op reboots

* fix(admin): disable idle transaction timer

---------

Co-authored-by: nomdetom <nomdetom@protonmail.com>
NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Aug 12, 2026
Four review findings.

broadcast_smart_minimum_interval_secs is not read live, despite the comment this
branch added claiming it was. PositionModule::minimumTimeThreshold is a const
data member initialised when the module is constructed, so the value is captured
at boot; only the sibling distance field is genuinely re-read (PositionModule.cpp
:630). The InkHUD menu therefore applied it with no reboot and it silently did
nothing until the next restart. It now reboots, matching the AdminModule path.
Note the review suggested the opposite fix - adding the field to AdminModule's
live-field list - which would have made the admin path silently ineffective too.

While an edit transaction is open saveChanges() defers the write, which threw
away the per-field reboot and radio decisions; the commit then used the parameter
defaults and always rebooted and reconfigured the radio. Since phone apps write
config through transactions, none of this branch's narrowing reached them. The
deferred decisions now accumulate and the commit honours them.

Two test fixes: the set_ignored_node test relied on an earlier RUN_TEST having
created the node, which breaks under -f or a registration reorder. And the
telemetry test asserted only values it had just assigned - replaced with one that
drives the real toggle and checks the save path sets has_telemetry, the flag
whose absence caused the TAK persistence bug fixed upstream in meshtastic#11216.

clod helped too
NomDeTom added a commit to NomDeTom/MeshtasticFirmware that referenced this pull request Aug 12, 2026
* reconfigure tests

* Narrow radio-reload trigger to LoRa-affecting saves only

reloadConfig() inferred "the radio needs reconfiguring" from whether
saveWhat included SEGMENT_CONFIG. But Config is a monolithic segment
(device/position/power/network/display/lora/bluetooth/security share one
file), so every non-LoRa AdminModule save still fired the live SX126x
reconfigure - the same live-SPI-on-the-admin-thread sequence that crashes
on a favorite (meshtastic#11146), just reached via a Bluetooth toggle, WiFi PSK
change, or keypair rotation.

Separate the two concerns: add an explicit radioAffected flag threaded
through AdminModule::saveChanges() into MeshService::reloadConfig(). The
gate becomes `radioAffected && (saveWhat & (SEGMENT_CONFIG|SEGMENT_CHANNELS))`
- the bitmask inference is kept as a backstop and only suppressible, and
radioAffected defaults to true so the ~35 existing reloadConfig() callers
(MenuHandler, MenuApplet, portduino) keep today's behavior untouched.

Only handleSetConfig()'s lora_tag sets radioAffected; every other
sub-message, plus set_fixed_position/remove_fixed_position and position's
nested save, opt out. set_channel/commit_edit_settings/restore keep the
default and still reload.

Add native coverage in test_admin_radio for each non-LoRa sub-message
(asserting no reconfigure), regression guards that lora/set_channel still
reconfigure, and direct reloadConfig() guards pinning the fail-safe default.

* mark up todo sites for checking reloadConfig use

* added clarifying note

* Don't reboot on no-op position/network/bluetooth config sets

handleSetConfig() starts requiresReboot=true and device/power/display each
gate it back down when nothing reboot-worthy changed, but position,
network, and bluetooth never touched requiresReboot - so they rebooted on
*every* set, including a client re-pushing byte-identical config (which
happens routinely on connect/retry).

Add a no-op gate to those three: if the incoming sub-message is
byte-identical to the current one, skip the reboot. A whole-struct memcmp
is the right tool here (unlike the field-by-field device/power/display
gates, which must ignore benign fields) - it answers "did anything change?"
and fails safe: its only error mode is padding-byte differences causing an
unnecessary reboot, never a missed change. All three sub-messages are POD
(no pb_callback_t fields). Any real change still reboots exactly as before.

This is Tier 1 of plan-narrow-reboot-trigger; Tier 2 will further narrow
position to reboot only on boot-only fields (GPIO/GPS).

Adds native coverage asserting rebootAtMsec stays unset on a no-op set and
is armed on a real change, for each of the three sub-messages.

* Apply live position config changes without a reboot

Tier 2 of plan-narrow-reboot-trigger. A position set that touches only
fields the position module consumes live now applies without restarting.

PositionModule reads position_broadcast_secs, position_broadcast_smart_enabled,
broadcast_smart_minimum_distance, position_flags, and fixed_position directly
from config on every send/schedule cycle (fixed_position also has dedicated
live admin handlers) - changing only those needs no reboot. Everything else
stays on the reboot path: GPS driver state (gps_mode/gps_enabled/
gps_update_interval/gps_attempt_time) and GPIO pin assignments (rx_gpio/
tx_gpio/gps_en_gpio), all of which touch subsystem/hardware init.

The live set is deliberately limited to what static analysis proves is
applied live, so this ships without hardware verification; GPS-timing fields
that might also be live were left rebooting (fail toward current behavior).
The gate neutralizes the live fields in a copy and reboots if any other byte
differs, so a future PositionConfig field reboots until explicitly cleared
as live - fail safe for schema growth.

This supersedes the Tier 1 no-op memcmp gate for position (network/bluetooth
keep theirs). Native coverage: a broadcast-interval change does not schedule
a reboot; gps_mode and rx_gpio changes still do.

* docs: config-save radio-reload & reboot gating

Document the AdminModule config-save side-effect work: the radioAffected
and requiresReboot axes, which operations now skip the radio reload or the
reboot, and the operations deliberately left unchanged (commit_edit_settings,
network/bluetooth live-apply, GPS-timing position fields, module config,
and the on-device menu reloadConfig sites) with the reason for each.

* docs: add hardware testing section to config-save gating

The doc only mentioned the outstanding GPS-timing pass in one line and
omitted the radio-reload/crash validation entirely, with no procedure.
Add a Hardware testing section covering: the meshtastic-mcp setup
(BLE connected throughout, nRF52840 SX126x reference board); the
radio-reload/crash regression guard for the favorite-node fix (with a
lora/set_channel positive control); and a per-field procedure with
pass/fail criteria for deciding whether GPS-timing position fields can be
reclassified as live - fail toward rebooting.

* docs: clarify hardware tests run over serial, not menus/BLE

The hardware section implied a BLE connection was required and that the
crash ran on the BLE callback thread. On nRF52 the BLE onWrite only queues;
handleToRadio -> saveChanges -> reloadConfig runs on the main FreeRTOS task,
same context SerialConsole uses. So serial drives the exact code/thread
under test, the on-device menus are unrelated (separate path), and the
original crash was serial-proven. State that serial is sufficient and
correct the thread references accordingly.

* docs: correct crash mechanism to the off-main-thread spiLock lockup

The previous edit, while right that the reconfigure is invoked on the main
task (not the BLE callback thread), oversimplified the failure. The crash
is cross-thread: the main-task setStandby()+SPI reprogram collides with the
radio's off-main NotifiedWorkerThread over the shared non-recursive spiLock
(FreeRTOS binary semaphore), locking it up and tripping the watchdog reboot
(meshtastic#11146; same spiLock hazard as meshtastic#10705/meshtastic#10728). Restore that in both the
Axis 1 rationale and the hardware test-1 description. Does not change the
serial-is-sufficient conclusion (the collision is transport-independent).

* docs: clarify config save behavior for GPS position updates

* menu actions

* menu actions reboot

* Persist telemetry screen toggles from the frame menu

The three moduleConfig.telemetry.*_screen_enabled toggles in the frame-toggle
menu changed the value in memory and never wrote it, so they reverted on any
reboot that did not happen to go through the reboot menu (which saves every
segment). Their sibling entries in the same menu persist via
Screen::toggleFrameVisibility -> saveFrameVisibility, so half the menu was
durable and three entries were not.

These three live in moduleConfig rather than the hiddenFrames blob, so they need
their own SEGMENT_MODULECONFIG save. radioAffected is false: a screen preference
has no business re-initialising the LoRa chip.

clod helped too

* Drop redundant and over-broad config writes from the menus

reloadConfig() ends with an unconditional saveToDisk(saveWhat), outside the
radioAffected guard, so nine sites in the InkHUD menu were writing the same
proto file twice in a row: saveToDisk(X) immediately followed by
reloadConfig(X, ...). One of the nine is the shared applyConfigReload() helper,
so thirteen menu actions were affected.

Five sites in the BaseUI menu called saveUIConfig() after changing a field that
lives in config.proto. saveUIConfig() only writes /prefs/uiconfig.proto, so
those writes could not persist the changed field and did nothing but cost a
flash write. GPSFormatMenu had the mirror image: a uiconfig-only change that
also called reloadConfig(), rewriting config.proto for a field not in it.

Three bare saveToDisk() calls rewrote all five segments to change one bit -
a channel mute flag and two NodeInfoLite bits - so they now pass the segment
they actually touch. NodeInfoLite is written by saveNodeDatabaseToDisk() under
SEGMENT_NODEDATABASE, not SEGMENT_DEVICESTATE. The reboot menu keeps its bare
call: a full flush before a deliberate reboot is correct.

reloadConfig() becomes virtual so tests can count calls; the accompanying test
pins down the saveToDisk equivalence the nine deletions rely on.

clod helped too

* Route config saves through one MeshService::applyConfigChange helper

Applying a config change involves three independent decisions: which proto files
to persist, whether the LoRa chip needs re-initialising, and whether the field
only takes effect after a restart. Those were spread across four helpers with
three different parameter orders, two of which took adjacent bools that compile
fine when transposed. InkHUD's applyConfigReload was the sharp edge: its second
parameter was `reboot`, sitting exactly where reloadConfig and saveChanges take
`radioAffected`.

There is now a single entry point taking a flags enum, so each call site states
its intent and cannot silently mean the opposite. Migrated 23 BaseUI sites, 20
InkHUD sites and the wasm glue. applyConfigReload is deleted and its five
callers inlined; applyLoRaRegion and applyLoRaPreset stay, since they hold real
domain logic beyond bundling, and just delegate.

Adjacent `rebootAtMsec = millis() + ...` assignments fold into the reboot flag.
The one caller that deliberately uses a shorter delay keeps it via the trailing
rebootSeconds parameter. Reboot scheduling itself moves into requestReboot() in
main.cpp, next to the global it sets: previously only AdminModule had a helper
for this, and it was private, which is why every menu open-coded the deadline.
requestReboot carries no UI, because BaseUI already renders the notice at draw
time whenever rebootAtMsec is set.

saveChanges keeps its signature and its edit-transaction deferral, so its
fourteen call sites and the existing gating tests are untouched.

clod helped too

* Extract menu config actions into testable functions

None of the menu save behaviour was reachable from a test: every action lived in
a lambda assigned to BannerOverlayOptions.bannerCallback, which only ever runs
via screen->showOverlayBanner(), so exercising one needed a live Screen. That is
why the defects this series fixes went unnoticed - MenuHandler.cpp compiles in
the native test build, but nothing in it could be called.

Three actions move out into functions that own the whole decision: which segment
to persist, whether the radio needs reconfiguring, and whether to reboot. Chosen
because each covers a defect this series touched - the telemetry toggles that
never persisted, the smart-position reboot that PR meshtastic#11181 removed, and a node-DB
bit write that must not reach the radio.

Deliberately picked extractions that also deduplicate: three telemetry call
sites collapse onto one function and two smart-position sites onto another, so
the promicro image is byte-identical to before. Worth knowing, because that board
has under 1.4 KB of headroom before the warmstore region.

toggleNodeMuted also gains a guard for an unknown node, so a stale
pickedNodeNum can no longer cause a pointless flash write.

clod helped too

* Sort out InkHUD applying-changes notifications

notifyApplyingChanges() was doing two jobs at its call sites in MenuApplet.cpp:
signalling a live change that is applied without restarting, and warning of an
imminent reboot. Audited every site. The finding is that none of them are
redundant, so this records why rather than removing anything.

The two live-change calls - LoRa region and modem preset - cover the seconds the
e-ink takes to redraw while the radio reconfigures, and nothing else raises them.
The reboot-path calls warn before the display goes. requestReboot() cannot absorb
those: it deliberately carries no UI, because BaseUI renders its own notice at
draw time from rebootAtMsec, while e-ink only draws when pushed.

Also worth recording: the existing notifyReboot Observable (sleep.h, fired from
Power::reboot) is a different moment - reboot execution, not scheduling - and
InkHUD already observes it to save settings and shut applets down. A new
"reboot scheduled" observable to centralise these calls would be a third reboot
signal for no functional gain, so it is not added.

clod helped too

* stylee

* Fix stale doc references and document the menu path

Addresses review feedback.

Four comments pointed at plan documents that were never committed
(plan-narrow-reboot-trigger.md, plan-decouple-nodedb-admin-saves.md), so the
rationale they cited was not discoverable. They now point at
docs/admin-config-save-gating.md, which is in-repo.

The doc listed the commit SHAs it described. All five had already been orphaned
by the rebases this branch has been through, exactly as predicted, so it cites
the PR instead. "Status: Implemented" also overstated things while hardware
validation is still outstanding.

The doc's biggest problem was that it had gone out of date within its own PR: it
described the on-device menus as an untouched code path that still reloads the
radio on any Config save, with per-site TODO markers. That stopped being true
when the menus moved onto applyConfigChange. Replaced with a section covering the
new entry point, the flags, why they are a flags enum rather than two bools, and
the saveToDisk equivalence that makes pairing the two calls a double write.

clod helped too

* Fix smart-broadcast-interval reboot and carry transaction flags

Four review findings.

broadcast_smart_minimum_interval_secs is not read live, despite the comment this
branch added claiming it was. PositionModule::minimumTimeThreshold is a const
data member initialised when the module is constructed, so the value is captured
at boot; only the sibling distance field is genuinely re-read (PositionModule.cpp
:630). The InkHUD menu therefore applied it with no reboot and it silently did
nothing until the next restart. It now reboots, matching the AdminModule path.
Note the review suggested the opposite fix - adding the field to AdminModule's
live-field list - which would have made the admin path silently ineffective too.

While an edit transaction is open saveChanges() defers the write, which threw
away the per-field reboot and radio decisions; the commit then used the parameter
defaults and always rebooted and reconfigured the radio. Since phone apps write
config through transactions, none of this branch's narrowing reached them. The
deferred decisions now accumulate and the commit honours them.

Two test fixes: the set_ignored_node test relied on an earlier RUN_TEST having
created the node, which breaks under -f or a registration reorder. And the
telemetry test asserted only values it had just assigned - replaced with one that
drives the real toggle and checks the save path sets has_telemetry, the flag
whose absence caused the TAK persistence bug fixed upstream in meshtastic#11216.

clod helped too

* Stop the edit transaction discarding the per-field save decisions

Review of this branch's own output, plus the two findings Copilot raised on

The headline defect is that none of this branch's narrowing reached phone apps.
saveChanges() defers while an edit transaction is open, and radioAffected
defaulted to true, so eight call sites that never thought about the radio - the
five node-DB handlers, set_owner, set_module_config, and the nested save in the
position case - accumulated a true into deferredRadioAffected. Outside a
transaction that was harmless, because reloadConfig()'s
saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS) bitmask independently blocks a
node-DB-only save from reaching the radio whatever it asks for. Inside one the
commit saves under a fixed full mask, so the bitmask always passes and
radioAffected is the only thing left deciding it. Favouriting a node from the
phone app therefore still ran the live SX126x reconfigure at commit - the

The fix is to remove the default from saveChanges() rather than gate the
deferred flag on the segment mask, which is what the review suggested. Gating
would reinstate exactly the segment-to-radio inference this branch exists to
delete - NodeDB.h now carries a comment telling the next person not to do that -
and it fixes the symptom while leaving the wrong default in place for the next
call site. With no default the compiler makes all fourteen sites state the
answer. The commit also consumes and clears the deferred flags, so a stray
second commit cannot inherit the previous transaction's answer.

That gap existed because every radio test ran outside a transaction, which is
the one arrangement where the bug is unreachable. Nine tests now cover the
deferred path, asserting both axes independently across a commit. Verified they
fail against the old behaviour: five go red while every pre-existing test stays
green, which is the point.

requestReboot()'s comment had the semantics backwards - it claimed a negative
delay meant "now" and that rebootAtMsec == 0 was an immediate-reboot sentinel.
Both are inverted: 0 means no reboot pending at every read site, and
admin.proto documents reboot_seconds "<0 to cancel reboot". The expression was a
faithful copy of AdminModule's, so only the comment was wrong, but it was wrong
on the newly-created central helper. The negative branch now says so and logs
it. Five sites still open-coded the deadline despite that helper existing; they
are pure reboots with no config save, so applyConfigChange() does not fit but
requestReboot() does exactly.

SET_SMART_BROADCAST_INTERVAL was the only CONFIG_APPLY_REBOOT site in the InkHUD
menu without a notifyApplyingChanges() beside it - re-adding its reboot last
commit dropped the warning that applyConfigReload() used to raise, so the e-ink
would go dark unannounced. And four channel actions carried CONFIG_APPLY_RADIO
for uplink/downlink and position_precision, none of which touch the name, PSK or
frequency slot the radio derives anything from; they had it only because the old
reloadConfig(SEGMENT_CHANNELS) inferred it from the bitmask.

The doc had gone stale inside its own PR again: it still listed
commit_edit_settings under "intentionally left unchanged". Replaced with a
section on why the bitmask stops protecting you inside a transaction, since that
is the non-obvious part. Also dropped a commit SHA that had already been
orphaned by rebase, exactly as its own note predicted, and a stale plan-doc
reference in the tests that the last doc pass missed.

Full native suite green, 810 cases across 40 suites. t-echo and t-echo-inkhud
both build, covering the menu changes no native env compiles.

* post rebase fixes

* gps-toggle-noreboot

* fix(admin): preserve live config transactions

* fix(admin): avoid unnecessary config restarts

* fix(admin): keep edit timeout dormant while idle

* fix(admin): skip normalized no-op reboots

* fix(admin): disable idle transaction timer

---------

Co-authored-by: nomdetom <nomdetom@protonmail.com>
@caveman99
caveman99 deleted the fw-6430-handoff branch August 26, 2026 19:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

2.8 bugfix Pull request that fixes bugs

Projects

None yet

2 participants