Merge PR #7 with reviewed ExpressLRS power and memory fixes

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.
This commit is contained in:
mikecarper
2026-09-10 22:06:16 -07:00
26 changed files with 1072 additions and 74 deletions
+13
View File
@@ -89,6 +89,17 @@ def requirements(platform, defines, target):
parts["core_filesystems_sensors"] = 8192
# Packet bytes plus all three queue tables and allocation overhead.
parts["radio_packet_pool"] = 5120 if companion else 10240
# These tables moved out of .bss, so linker heap bounds now include their
# space. Count it as startup allocation instead; C++ assertions bind the
# per-entry bounds to the production structures.
if re.search(r"repeater|room_server|sensor", target, re.I) or "COMPANION_MESH_CLOCK_SYNC" in defines:
parts["client_table"] = integer(defines, "MAX_CLIENTS", 32) * 320 + 16
if "repeater" in target.lower():
engine = integer(defines, "MESH_ENABLE_FLOOD_RULE_ENGINE", int(platform != "STM32_PLATFORM"))
slots = integer(defines, "FLOOD_PACKET_FILTER_SLOTS", 63 if engine else 16)
parts["flood_filter_table"] = slots * (200 if engine else 40) + 16
if "ENABLE_OTA" in defines and "OTA_HEAP_CONTEXT" in defines:
parts["ota_context"] = 16384 + 16
if display and display != "NullDisplayDriver":
parts["display_pixels_and_driver"] = display_heap
parts["screen_objects_and_history"] = 8192 if companion else 2048
@@ -113,6 +124,8 @@ def requirements(platform, defines, target):
largest = max(display_heap, parts["radio_packet_pool"], 8192 if platform == "ESP32_PLATFORM" else 0)
if "expanded_message_previews" in parts:
largest = max(largest, 8192 + parts["expanded_message_previews"])
for name in ("client_table", "flood_filter_table", "ota_context"):
largest = max(largest, parts.get(name, 0))
return {"required_heap_bytes": required, "required_contiguous_bytes": largest,
"components": parts, "display": display, "full_companion": full}
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Keep the idle mOTA workspace off classic ESP32's static DRAM.
Classic ESP32 links into a dram0_0_seg of ~124 KiB: memory.ld reserves the BT
controller's 0xdb5c bytes off the 0x2c200 window before the application gets
any, and no runtime call gives those static bytes back. OtaContext is several
kilobytes of that budget, held for the life of the device even though a
repeater or room server is outside an OTA window essentially always.
OTA_HEAP_CONTEXT switches OtaContext to the on-demand storage path, so it is
allocated when an OTA operation needs it and freed once idle. Applied here
rather than per-env so every classic ESP32 build gets it and none can drift.
Scoped to build.mcu == "esp32" deliberately. S2/S3/C-series and nRF52 have no
equivalent static-DRAM ceiling, so there the .bss singleton is the better
trade: it guarantees the workspace is present, and OTA is a recovery path.
This must be a build flag, not a header default. OTA_HEAP_CONTEXT has to hold
the same value in every translation unit that sees OtaContext.h - including
OtaContext.cpp, which defines the storage - and a header test on
CONFIG_IDF_TARGET_ESP32 would depend on whether that unit had already reached
sdkconfig.h.
"""
import re
Import("env") # noqa: F821 -- PlatformIO/SCons supplies Import
# Both name the owner of the context storage, and OtaContext.h rejects the
# pair. OTA_SHARED_COMPANION_QUEUE arrives via PLATFORMIO_BUILD_FLAGS from
# build.sh's Full Companion recipes, which are nRF52/S3 today - but a future
# classic ESP32 Full Companion must lose this default, not fail to compile.
CONFLICTING = ("OTA_HEAP_CONTEXT", "OTA_SHARED_COMPANION_QUEUE")
def _already_defined(env):
for define in env.get("CPPDEFINES", []):
name = define[0] if isinstance(define, (list, tuple)) else define
if str(name) in CONFLICTING:
return True
# PLATFORMIO_BUILD_FLAGS reaches BUILD_FLAGS as raw text, which may not be
# parsed into CPPDEFINES yet when this pre-script runs.
flags = env.get("BUILD_FLAGS", [])
text = flags if isinstance(flags, str) else " ".join(map(str, flags))
return bool(re.search(r"(?:^|\s)-D\s*(?:" + "|".join(CONFLICTING)
+ r")(?=[=\s]|$)", text))
def _apply(env):
if str(env.BoardConfig().get("build.mcu", "")).lower() != "esp32":
return
if _already_defined(env):
return
env.Append(CPPDEFINES=[("OTA_HEAP_CONTEXT", 1)])
_apply(env) # noqa: F821