From c6d71b969afffc4f39d1a05b13c6656b20fde674 Mon Sep 17 00:00:00 2001 From: MUSTARDTIGERFPV Date: Tue, 8 Sep 2026 17:13:13 -0700 Subject: [PATCH] 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) --- examples/simple_repeater/MyMesh.cpp | 5 +- examples/simple_room_server/MyMesh.cpp | 6 +++ examples/simple_sensor/SensorMesh.cpp | 6 +++ platformio.ini | 1 + scripts/esp32_ota_heap_context.py | 56 +++++++++++++++++++++ src/helpers/ota/OtaContext.h | 16 ++++++ variants/heltec_v2/platformio.ini | 1 - variants/lilygo_tbeam_SX1262/platformio.ini | 1 - 8 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 scripts/esp32_ota_heap_context.py diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index f85bdd1a..2979f802 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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 } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 862fb8f7..dba77731 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,5 +1,8 @@ #include "MyMesh.h" #include +#if defined(ENABLE_OTA) +#include +#endif #include #include #include @@ -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 { diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index a57c8db4..8621137b 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -5,6 +5,9 @@ #include #include #include +#if defined(ENABLE_OTA) +#include +#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 { diff --git a/platformio.ini b/platformio.ini index 6375a326..9a5f3d51 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/scripts/esp32_ota_heap_context.py b/scripts/esp32_ota_heap_context.py new file mode 100644 index 00000000..0e109a5a --- /dev/null +++ b/scripts/esp32_ota_heap_context.py @@ -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 diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index a2052593..eec66c5a 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -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 diff --git a/variants/heltec_v2/platformio.ini b/variants/heltec_v2/platformio.ini index 6c194b29..198a1778 100644 --- a/variants/heltec_v2/platformio.ini +++ b/variants/heltec_v2/platformio.ini @@ -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} diff --git a/variants/lilygo_tbeam_SX1262/platformio.ini b/variants/lilygo_tbeam_SX1262/platformio.ini index ce863715..2f5d94e6 100644 --- a/variants/lilygo_tbeam_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_SX1262/platformio.ini @@ -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