Integrate ExpressLRS TX module support and its stacked ESP32 heap changes.
Use the approved Linkflow calibration (17-30 dBm), preserve the PA drive
and output path after radio recovery, and keep LoRa OTA enabled.
Preserve stored ACLs and filters on allocation failure, release owned
client/filter buffers, service heap OTA contexts on Companions, release
self-serving workspaces after TempRadio, and reset staged-resume state
when a context is released. Keep manual staging and active operations
alive. Account for all moved allocations in the runtime RAM gate.
Validation: 1,393 native cases; radio-power, heap-context, ACL persistence,
shared-queue transfer, display/inbox, radio receive, and memory regressions.
Firmware builds passed for Linkflow, Heltec V2 Companion, T-Beam MQTT
repeater, Heltec V4 R8 MQTT repeater, RAK4631 repeater, and Indicator Full.
Physical verification awaits access to the currently offline lab Pi.
Move OTA_HEAP_CONTEXT from two hand-edited envs to a pre-script gated on
build.mcu == "esp32", so every classic ESP32 image gets it and none can drift.
S2/S3/C-series and nRF52 keep the .bss singleton: they have no equivalent
static-DRAM ceiling, so there a guaranteed-present workspace is the better
trade for what is ultimately a recovery path. It has to be a build flag rather
than a header default, because OTA_HEAP_CONTEXT must hold the same value in
every translation unit that sees OtaContext.h - OtaContext.cpp included - and a
header test on CONFIG_IDF_TARGET_ESP32 would depend on include order relative
to sdkconfig.h. The script defers to OTA_SHARED_COMPANION_QUEUE where a Full
Companion recipe already owns the storage.
Fix a gap this exposes in the roles that now use it. Only CLI entry points
acquire the context, so once it was released, a repeater with the temporary
radio profile up would no longer serve or announce its own firmware - LoRa OTA
would look enabled and silently do nothing. ota_service_temp_radio_context()
holds the workspace for exactly the temp-radio window and hands it back
outside it, which is where the saving was coming from anyway. Wired into the
repeater, room server and sensor loops; the Companion keeps its own
acquire-on-host-demand policy, since its context is borrowed from the offline
message queue and must not be taken speculatively.
Static DRAM, classic ESP32 (bytes occupied of a 124,580 region):
Heltec_v2_repeater 108,332 -> 70,732
Heltec_v2_room_server 83,716 -> 68,300
Heltec_v2_companion_radio_ble 123,236 -> 107,772 (was failing on main)
Tbeam_SX1262_repeater 86,932 -> 71,468
Tbeam_SX1262_repeater_observer_mqtt 121,132 -> 83,532 (was failing)
Add durable TempRadio, ACL, clock, bridge, GPS/I2C, USB logging, and Indicator recovery safeguards together with expanded automation, build/profile contracts, release metadata, and the hardware validation ledger.
Add selectable shared ESP-NOW channels, raw/wrapped bridge compatibility, and bounded fragmentation for maximum-size packets.\n\nGive ESP32 Full profiles WiFi coexistence, stabilize WebConfig and USB terminal lifecycles, and keep disabled bridges stopped during observer updates.\n\nUpdate generated UI, firmware selection, documentation, and regression coverage.
Two findings from review, both real, both mine.
The CLI could read secrets the portal has never exposed. CommonCLI splits its
surface by CALLER, not by command: a serial caller (sender_timestamp 0, physical
access) reads secrets in plaintext, a remote one gets "******** (serial only)".
Its own comments say so — "Serial only (WiFi creds grant LAN access); remote
sees set/unset". execCommand passes 0, which is what makes `erase`, `stats-*`
and `set freq` reachable at all, and with it the terminal inherited the serial
console's plaintext answers for an HTTP request: `get prv.key` returned this
node's identity, `get wifi.pwd` the operator's network.
Worse in setup mode, which authenticates by proximity to an open AP — and `start
webconfig ap` can be run on an already-configured node, so the secrets are real
by then, not blank.
I had reasoned that the AP was the trust boundary either way because the wizard
can already rewrite these. That conflated two capabilities: replacing a WiFi
password does not reveal the current one, and replacing an identity does not
reveal the existing private key. /api/config has always masked these on read
(wcIsSecretKey); the CLI simply broke that rule. Now only the READ is masked —
the command surface stays whole — in CommonCLI's own words, keeping the
set/unset signal that is the useful part.
Onboarding could also skip the mandatory password. handleConfigPost refuses to
arm a reboot during initial setup without one; the CLI only warned in the
browser, which a pasted script or a direct POST ignores, so a node could reboot
onto the LAN still holding the factory credential. Same rule now applies at
POST. It is satisfied by a `password` command anywhere in the session rather
than only in the same request, so the natural two-step console flow still works
— the form batch always sends both together and never needed that memory.
wcIsSecretReadCommand lives in WebConfigKeys.h beside the rest of the secret
classification, pinned by three host tests: what must be masked, what must not,
and that only reads are touched. 17 keys + 24 batch tests pass; the audit checks
a masked read round-trips as masked.
The full embedded string is v1.16.0.5-observer-beta-dev-a1b2c3d. The -observer
tag is on every observer build and the commit is for machines, so neither tells
a person anything at a glance. Both the page header and the console banner now
show base + published build number + channel, paired with the build date the
way `ver` pairs them:
v1.16.0.5-dev (6 Jun 2026)
v1.16.0.5 (6 Jun 2026)
`ver` still prints the whole string, commit included, for when that is what you
need. The channel suffix follows the release filenames rather than the embedded
tag — build.sh writes FILENAME_CHANNEL_TAG "-dev" for the same builds it tags
"-observer-beta-dev" internally, so "-dev" is the name these already carry.
Carrying the build date meant /api/status had to report it; WebConfigServer now
takes FIRMWARE_BUILD_DATE alongside FIRMWARE_VERSION, from the same defines
`ver` reads.
A local build has neither build number nor channel to show, so the fact worth
knowing about it moves to the second line: "local build, OTA not configured".
build.sh deliberately leaves OTA_MANIFEST_BASE undefined there, and a bare
version number gives no hint that the node cannot update itself.
The console showed "v1.16.0" — the version was there but buried in the header
line beside role and board, and on the build under test it genuinely had no more
to show: `pio run` never goes through build.sh, so no build number, no commit,
no OTA config.
The banner now prints FIRMWARE_VERSION whole and on its own line. Nothing was
truncating it; build.sh composes base[.build][-observer][-channel]-hash, so a CI
build already carries the published build number as a 4th component and the
commit as the trailing token — the two things that actually identify a build.
It also names the channel, which the version string encodes but does not spell
out (OTA_CHANNEL_TAG=beta-dev -> "-observer-beta-dev-"):
v1.16.0.5-observer-beta-dev-a1b2c3d (dev channel)
v1.16.0.5-observer-beta-a1b2c3d (beta channel)
v1.16.0.5-observer-a1b2c3d (release channel)
v1.16.0 (local build — not from CI, OTA not
configured)
That last one earns its wording: build.sh deliberately leaves OTA_MANIFEST_BASE
undefined on local builds so such a node cannot update itself, and nothing about
a bare version number says so.
The mock reports a build.sh-shaped version now (--fw-version switches channel),
and `ver` answers from the same string /api/status does, as both do on-device.
Five findings from review, all confirmed against the source.
Failure classification (P2). Testing replies for an "Err" prefix passed five
other shapes off as success: "Unknown command", "unknown config: x", "??: x",
"Can't find GPS", "(ERR: clock cannot go backwards)" and "File system erase:
Err". They rendered green, and worse, left _batch_all_ok true — so a queued
reboot went ahead after commands that had failed, defeating the gate entirely.
Rather than lengthen one guess, the two questions are now asked separately,
each erring safe:
- colour asks "does this look like a failure", against every shape CommonCLI
actually emits, enumerated in WebConfigBatch.h and pinned by a host test
that uses the literal strings. Getting this wrong is cosmetic.
- the reboot gate asks something narrower and answerable: "did every setting
I asked for take". Only `set`/`password` gate it, and only on the "OK"
prefix every setter keeps. Diagnostics no longer gate a reboot at all, so a
harmless `memory` cannot strand one and no guess is made about "> value".
Reboot deferral (P2). CommonCLI dispatches on a six-byte prefix, so `reboot
now` and `rebooted` reach Board::reboot() too. Matching exactly meant those
variants skipped both the confirmation and the deferral and took the node down
mid-drain — the precise failure deferral exists to prevent. Both sides now
anchor the way the firmware dispatches, and the UI's risk matcher with them.
Three commands the portal cannot honestly serve are refused at POST with a
reason, and dropped from autocomplete, instead of running and lying:
- `start ota` builds a second AsyncWebServer on port 80 with no bind check
and answers "Started" regardless; the portal already holds that port, so it
could only leak the allocation and inhibit sleep.
- `clock sync` takes its time from the caller's timestamp, which a web
request has none of, so CommonCLI always rejected it. `time <epoch>` works
and remains offered.
- bare `log` and `get acl` write their real output to Serial and hand back a
stub the terminal showed as success; `log` also streams a whole file from
the loop task, stalling the mesh and radio while it does.
The mock now emits the same failure shapes it used to fake as successes, so
these are reproducible off-hardware. 24 batch + 14 keys tests pass; audit
reports 119/119 answered, 0 missing, 4/4 refused with a reason.