Enable the on-demand mOTA context for every classic ESP32 build

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)
This commit is contained in:
MUSTARDTIGERFPV committed 2026-09-09 01:24:00 -07:00
1 parent f6bc7a194a
commit f0c4c6af00
8 files changed
+86 -6

No files matched your search

+1 -4
View File
@@ -12036,10 +12036,7 @@ void MyMesh::loop() {
processDeferredCliCommand();
servicePostMeshLoop();
#if defined(ENABLE_OTA) && OTA_DYNAMIC_CONTEXT
// Hand the OTA workspace back once no transfer, apply or folder link needs
// it. A repeater is idle almost all of its life, so this is where the bulk
// of the saving actually lands.
mesh::ota::ota_release_context_if_idle(isTempRadioActive());
mesh::ota::ota_service_temp_radio_context(isTempRadioActive());
#endif
}
+6
View File
@@ -1,5 +1,8 @@
#include "MyMesh.h"
#include <helpers/UsbLogging.h>
#if defined(ENABLE_OTA)
#include <helpers/ota/OtaContext.h>
#endif
#include <helpers/FileRead.h>
#include <helpers/radiolib/RxBoostedGainDefaults.h>
#include <helpers/CLICommandUtils.h>
@@ -2774,6 +2777,9 @@ void MyMesh::loop() {
}
}
#endif
#if defined(ENABLE_OTA) && OTA_DYNAMIC_CONTEXT
mesh::ota::ota_service_temp_radio_context(isTempRadioActive());
#endif
}
bool MyMesh::isMillisTimerDue(unsigned long timestamp) const {
+6
View File
@@ -5,6 +5,9 @@
#include <helpers/LazyPersistence.h>
#include <helpers/radiolib/RXPowerSaving.h>
#include <helpers/UsbLogging.h>
#if defined(ENABLE_OTA)
#include <helpers/ota/OtaContext.h>
#endif
static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) {
uint8_t shift = failure_count < 5 ? failure_count : 5;
@@ -1343,6 +1346,9 @@ void SensorMesh::loop() {
(unsigned long)retry_delay);
}
}
#if defined(ENABLE_OTA) && OTA_DYNAMIC_CONTEXT
mesh::ota::ota_service_temp_radio_context(isTempRadioActive());
#endif
}
bool SensorMesh::isMillisTimerDue(unsigned long timestamp) const {
+1
View File
@@ -74,6 +74,7 @@ extra_scripts =
pre:scripts/generate_webconfig_html.py
pre:scripts/meshcore_image_identity.py
pre:scripts/portable_esp32_link.py
pre:scripts/esp32_ota_heap_context.py
merge-bin.py
post:tools/mota/pio_endf.py
post:scripts/check_esp32_dram.py
+56
View File
@@ -0,0 +1,56 @@
#!/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("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.
for flag in env.get("BUILD_FLAGS", []):
text = str(flag)
if any(("-D" + name) in text for name in CONFLICTING):
return True
return False
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
+16
View File
@@ -681,6 +681,22 @@ uint8_t ota_hop_limit();
void ota_set_context_storage(void* owner, OtaContext* (*acquire)(void*),
void (*release)(void*));
void ota_release_context_if_idle(bool temporary_radio_active);
// Loop helper for roles whose LoRa OTA only runs under the temporary radio
// profile (repeater, room server, sensor). Nothing else acquires the context
// on their behalf: without this, serving and announcing would stop the moment
// the context was released, because only CLI entry points acquire. Holding it
// for the temp-radio window keeps behaviour identical to a permanent context,
// and the node is outside that window nearly all the time, which is where the
// saving comes from. Not for the Companion, whose context is borrowed from the
// offline message queue and must only be taken on explicit host demand.
inline void ota_service_temp_radio_context(bool temporary_radio_active) {
if (temporary_radio_active) {
ota_acquire_context(nullptr, 0); // failure is reported at the CLI entry points
} else {
ota_release_context_if_idle(false);
}
}
#endif
} // namespace ota
-1
View File
@@ -38,7 +38,6 @@ build_flags =
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
-D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; classic ESP32 DRAM cannot fit the default 255 with LoRa OTA
-D OTA_HEAP_CONTEXT=1 ; keep the idle mOTA workspace out of static DRAM
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${Heltec_lora32_v2.build_src_filter}
@@ -171,7 +171,6 @@ build_flags =
-D MAX_NEIGHBOURS=50
-D WITH_MQTT_BRIDGE=1
-D FLOOD_CHANNEL_SCOPE_SLOTS=31 ; retain scope support within this observer's tight internal DRAM
-D OTA_HEAP_CONTEXT=1 ; keep the idle mOTA workspace out of static DRAM
-D OTA_MANIFEST_BASE='"https://observer.gessaman.com/v"'
-D MQTT_MAX_PACKET_SIZE=1024
; -D MQTT_DEBUG=1