From 0d42d3c8e1fd68d3d65d401e9d540c8a2adc8cd7 Mon Sep 17 00:00:00 2001 From: MUSTARDTIGERFPV Date: Tue, 8 Sep 2026 15:54:21 -0700 Subject: [PATCH 1/9] Move large repeater tables off classic ESP32 static DRAM Classic ESP32 links into a dram0_0_seg of only 124,580 bytes: memory.ld carves the BT controller's 0xdb5c (56,156) reservation off the 0x2c200 window before the application gets any. Three large objects dominated what was left, and Tbeam_SX1262_repeater_observer_mqtt overran the build's 8 KiB static reserve by 4,744 bytes. Heap-allocate them instead, each with a defined behaviour when the allocation fails: - OtaContext: generalize the Companion's borrowed-storage path behind OTA_DYNAMIC_CONTEXT and add OTA_HEAP_CONTEXT, which allocates the context on first use and frees it once no transfer, apply or folder link needs it. A repeater is idle nearly all the time, so the workspace is usually absent. Exhaustion is reported to the caller and the operation declines. - ClientACL: the client table becomes a pointer with a live capacity; capacity 0 refuses new clients rather than writing through a null table. - MyMesh flood packet filters: likewise, with flood_packet_filter_slots as the live bound for every rule loop. At capacity 0 the node forwards unfiltered, and both save paths refuse to write so a stored ruleset is never replaced by an empty file. Also override the Arduino SDK's weak btInUse() in simple_repeater so initArduino() releases the BT controller memory to the heap. That grows the runtime heap these tables now come from; it cannot recover the same reservation from the linker's static window. Static DRAM, classic ESP32: Heltec_v2_repeater 108,332 -> 70,732 Tbeam_SX1262_repeater_observer_mqtt 121,132 -> 83,532 (was failing) --- examples/simple_repeater/MyMesh.cpp | 116 ++++++++++++-------- examples/simple_repeater/MyMesh.h | 9 +- examples/simple_repeater/main.cpp | 13 +++ src/Mesh.cpp | 19 ++-- src/helpers/ClientACL.cpp | 6 +- src/helpers/ClientACL.h | 14 ++- src/helpers/ota/OtaContext.cpp | 32 +++++- src/helpers/ota/OtaContext.h | 27 ++++- variants/heltec_v2/platformio.ini | 1 + variants/lilygo_tbeam_SX1262/platformio.ini | 1 + 10 files changed, 175 insertions(+), 63 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 4ed16397..f85bdd1a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -4,6 +4,7 @@ #include #include #include +#include // std::nothrow (heap-allocated flood rule table) #include // for qsort() #include #include @@ -3329,6 +3330,15 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc , bridge(&_prefs, _mgr, &rtc) #endif { + // Global constructors run before setup(), while the heap is still + // unfragmented. A failed allocation leaves flood_packet_filter_slots at 0: + // every rule loop is bounded by it, so the node forwards unfiltered instead + // of dereferencing a null table. saveFloodPacketFilters() refuses to write + // in that state so a stored ruleset is never overwritten with an empty one. + flood_packet_filters = + new (std::nothrow) FloodPacketFilterEntry[FLOOD_PACKET_FILTER_SLOTS]; + flood_packet_filter_slots = flood_packet_filters ? FLOOD_PACKET_FILTER_SLOTS : 0; + static_cast(_mgr)->setFloodScopePreference( scoreFloodTransportScope, this); last_millis = 0; @@ -3380,7 +3390,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc recv_pkt_channel_scope_bypass = false; recv_pkt_channel_scope_rejected = false; recv_pkt_filter_match_mask = 0; - memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + memset(flood_packet_filters, 0, sizeof(FloodPacketFilterEntry) * flood_packet_filter_slots); flood_packet_filter_blacklist_count = 0; memset(flood_packet_filter_blacklist, 0, sizeof(flood_packet_filter_blacklist)); memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes)); @@ -5667,6 +5677,9 @@ static void formatFloodModerationPath( const uint8_t path[FLOOD_GROUP_MODERATION_PATH_BYTES_MAX]); void MyMesh::seedDefaultFloodPacketFilters() { + // Slots 0 and 1 carry the built-in defaults; without a table there is + // nothing to seed and the node forwards unfiltered. + if (flood_packet_filter_slots < 2) return; auto& entry = flood_packet_filters[0]; memset(&entry, 0, sizeof(entry)); entry.active = true; @@ -5713,7 +5726,7 @@ bool MyMesh::loadFloodPacketFilters() { enum class FileState : uint8_t { Missing, Valid, Invalid, Unreadable }; auto loadFile = [this](const char* filename) -> FileState { - memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + memset(flood_packet_filters, 0, sizeof(FloodPacketFilterEntry) * flood_packet_filter_slots); flood_channel_data_rule_slot = 0xFF; flood_channel_data_rule_max_hops = FLOOD_CHANNEL_HOPS_ALL; memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes)); @@ -5737,7 +5750,7 @@ bool MyMesh::loadFloodPacketFilters() { success = (version_6 || version_7) && file.read(&count, sizeof(count)) == sizeof(count) && FloodFilterPolicy::forwardPersistenceCountSupported( - count, FLOOD_PACKET_FILTER_SLOTS); + count, flood_packet_filter_slots); for (int i = 0; success && i < count; i++) { uint8_t active = 0; @@ -6072,7 +6085,7 @@ bool MyMesh::loadFloodPacketFilters() { } file.close(); if (!success) { - memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + memset(flood_packet_filters, 0, sizeof(FloodPacketFilterEntry) * flood_packet_filter_slots); memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes)); memset(flood_channel_direct_scopes, 0, sizeof(flood_channel_direct_scopes)); @@ -6177,7 +6190,7 @@ bool MyMesh::isFloodChannelDataRule( } int MyMesh::findFloodChannelDataRule() const { - if (flood_channel_data_rule_slot >= FLOOD_PACKET_FILTER_SLOTS) return -1; + if (flood_channel_data_rule_slot >= flood_packet_filter_slots) return -1; return isFloodChannelDataRule( flood_packet_filters[flood_channel_data_rule_slot]) ? flood_channel_data_rule_slot : -1; @@ -6230,7 +6243,7 @@ void MyMesh::setFloodChannelData(const char* value, char* reply) { int current = findFloodChannelDataRule(); int slot = current; if (!enable && slot < 0) { - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { if (!flood_packet_filters[i].active) { slot = i; break; @@ -6311,7 +6324,7 @@ bool MyMesh::migrateLegacyFloodChannelData() { if (_prefs.flood_channel_data_enabled) return true; int slot = -1; - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { if (!flood_packet_filters[i].active) { slot = i; break; @@ -6437,7 +6450,7 @@ bool MyMesh::migrateLegacyFloodChannelBlocks() { bool duplicate = false; int free_slot = -1; - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { const auto& entry = flood_packet_filters[i]; if (!entry.active) { if (free_slot < 0) free_slot = i; @@ -6498,6 +6511,9 @@ bool MyMesh::migrateLegacyFloodChannelBlocks() { bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase, bool empty_forward_phase) { if (_fs == NULL) return false; + // Without a rule table there is nothing to persist. Writing the file anyway + // would replace the operator's stored ruleset with an empty one. + if (flood_packet_filter_slots == 0) return false; // Recovery owns transaction remnants; overwriting one could erase the only // complete image after a failed publish boundary. if (_fs->exists(FLOOD_PACKET_FILTER_TEMP_FILE) @@ -6520,7 +6536,7 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase, const uint8_t magic[4] = {'F', 'P', 'F', '7'}; const uint8_t count = FloodFilterPolicy::forwardPersistenceCount( - flood_packet_filters, FLOOD_PACKET_FILTER_SLOTS, + flood_packet_filters, flood_packet_filter_slots, empty_forward_phase); bool success = writeExact(magic, sizeof(magic)) && writeExact(&count, sizeof(count)); @@ -6643,7 +6659,7 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase, } #else bool MyMesh::loadFloodPacketFilters() { - memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + memset(flood_packet_filters, 0, sizeof(FloodPacketFilterEntry) * flood_packet_filter_slots); if (_fs == NULL) { seedDefaultFloodPacketFilters(); return true; @@ -6663,7 +6679,7 @@ bool MyMesh::loadFloodPacketFilters() { bool version_6 = success && memcmp(magic, "FPF6", sizeof(magic)) == 0; success = version_6 && file.read(&count, sizeof(count)) == sizeof(count) - && count <= FLOOD_PACKET_FILTER_SLOTS; + && count <= flood_packet_filter_slots; for (int i = 0; success && i < count; i++) { uint8_t active = 0; @@ -6728,7 +6744,7 @@ bool MyMesh::loadFloodPacketFilters() { file.close(); // A truncated or invalid file fails open; filtering must never be enabled by corrupt bytes. - if (!success) memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + if (!success) memset(flood_packet_filters, 0, sizeof(FloodPacketFilterEntry) * flood_packet_filter_slots); return success; } @@ -6736,16 +6752,18 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase, bool empty_forward_phase) { (void)empty_scope_phase; if (_fs == NULL) return false; + // As above: never replace a stored ruleset with an empty file. + if (flood_packet_filter_slots == 0) return false; File file = openFloodSettingsWrite(_fs, FLOOD_PACKET_FILTER_FILE); if (!file) return false; const uint8_t magic[4] = {'F', 'P', 'F', '6'}; - uint8_t count = FLOOD_PACKET_FILTER_SLOTS; + uint8_t count = flood_packet_filter_slots; bool success = file.write(magic, sizeof(magic)) == sizeof(magic) && file.write(&count, sizeof(count)) == sizeof(count); FloodPacketFilterEntry empty_entry; memset(&empty_entry, 0, sizeof(empty_entry)); - for (int i = 0; success && i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; success && i < flood_packet_filter_slots; i++) { const auto& entry = empty_forward_phase ? empty_entry : flood_packet_filters[i]; uint8_t active = entry.active ? 1 : 0; @@ -6862,7 +6880,7 @@ bool MyMesh::authenticateFloodPacketFilterChannel( } bool MyMesh::hasFloodPacketFilterRetryRules() const { - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { if (flood_packet_filters[i].active && flood_packet_filters[i].retry_on_match) return true; } @@ -6870,7 +6888,7 @@ bool MyMesh::hasFloodPacketFilterRetryRules() const { } bool MyMesh::floodPacketFilterAllowsRetry(uint64_t match_mask) const { - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { if ((match_mask & ((uint64_t)1U << i)) != 0 && flood_packet_filters[i].retry_on_match) return true; } @@ -6881,14 +6899,14 @@ int MyMesh::nextFloodPacketFilterMatch(uint64_t match_mask, uint64_t visited_mask) const { uint8_t priorities[FLOOD_PACKET_FILTER_SLOTS]; uint8_t specificities[FLOOD_PACKET_FILTER_SLOTS]; - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { priorities[i] = flood_packet_filters[i].priority; specificities[i] = FloodFilterPolicy::channelMatcherSpecificity( flood_packet_filters[i].channel_key_len); } return FloodFilterPolicy::nextOrderedRule( match_mask, visited_mask, priorities, specificities, - FLOOD_PACKET_FILTER_SLOTS); + flood_packet_filter_slots); } bool MyMesh::resolveFloodPacketFilterTargetRegion( @@ -6910,7 +6928,7 @@ uint64_t MyMesh::applyFloodPacketFilterStop(uint64_t match_mask) { uint8_t priorities[FLOOD_PACKET_FILTER_SLOTS]; uint8_t specificities[FLOOD_PACKET_FILTER_SLOTS]; uint8_t stop_flags[FLOOD_PACKET_FILTER_SLOTS]; - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { priorities[i] = flood_packet_filters[i].priority; const auto& entry = flood_packet_filters[i]; specificities[i] = FloodFilterPolicy::channelMatcherSpecificity( @@ -6928,7 +6946,7 @@ uint64_t MyMesh::applyFloodPacketFilterStop(uint64_t match_mask) { } return FloodFilterPolicy::truncateRulesAtStop( match_mask, priorities, specificities, stop_flags, - FLOOD_PACKET_FILTER_SLOTS); + flood_packet_filter_slots); } uint64_t MyMesh::evaluateFloodPacketFilterMatches( @@ -6944,7 +6962,7 @@ uint64_t MyMesh::evaluateFloodPacketFilterMatches( bool channel_auth_checked[FLOOD_PACKET_FILTER_SLOTS] = { false }; bool channel_auth_valid[FLOOD_PACKET_FILTER_SLOTS] = { false }; uint64_t matches = 0; - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { const auto& entry = flood_packet_filters[i]; if (!floodPacketFilterFieldsMatch( entry, packet, incoming_is_scoped, incoming_transport_code, @@ -7056,7 +7074,7 @@ bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet) const { void MyMesh::commitFloodPacketFilterRates(const mesh::Packet* packet) { if (packet == NULL || !packet->isRouteFlood()) return; uint32_t now = _ms->getMillis(); - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { auto& entry = flood_packet_filters[i]; if ((recv_pkt_filter_match_mask & ((uint64_t)1U << i)) == 0 || !entry.rate_limit_enabled) { @@ -7098,7 +7116,7 @@ uint64_t MyMesh::evaluateFloodPacketFilterMatches( const RegionEntry* incoming_region) { (void)incoming_region; uint64_t matches = 0; - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { const auto& entry = flood_packet_filters[i]; if (floodPacketFilterFieldsMatch(entry, packet, false, 0, incoming_region_allowed, NULL) @@ -7118,7 +7136,7 @@ bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet, scope_set = false; fast_track = false; if (packet == NULL || !packet->isRouteFlood()) return false; - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { const auto& entry = flood_packet_filters[i]; if ((match_mask & ((uint64_t)1U << i)) == 0 || entry.scope_name[0] == 0) continue; @@ -7145,7 +7163,7 @@ bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet) const { if (packet == NULL || !packet->isRouteFlood()) return false; uint8_t type = packet->getPayloadType(); uint8_t hops = packet->getPathHashCount(); - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { const auto& entry = flood_packet_filters[i]; if ((recv_pkt_filter_match_mask & ((uint64_t)1U << i)) != 0 && entry.scope_name[0] == 0) { @@ -7175,7 +7193,7 @@ static bool floodRuleRegionNamePresent(const RegionMap& map, } void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const { - if (index < 0 || index >= FLOOD_PACKET_FILTER_SLOTS || !flood_packet_filters[index].active) { + if (index < 0 || index >= flood_packet_filter_slots || !flood_packet_filters[index].active) { snprintf(reply, reply_len, "Err - empty filter slot"); return; } @@ -7336,8 +7354,8 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply) const { if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1); if (*selector != 0) { uint8_t slot; - if (!parseFloodFilterUnsigned(selector, FLOOD_PACKET_FILTER_SLOTS, slot) || slot == 0) { - snprintf(reply, 160, "Err - filter slot must be 1-%d", FLOOD_PACKET_FILTER_SLOTS); + if (!parseFloodFilterUnsigned(selector, flood_packet_filter_slots, slot) || slot == 0) { + snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots); return; } formatFloodPacketFilterDetail(slot - 1, reply, 160); @@ -7347,7 +7365,7 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply) const { size_t used = (size_t)snprintf(reply, 160, ">"); int active_count = 0; bool truncated = false; - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { const auto& entry = flood_packet_filters[i]; if (!entry.active) continue; active_count++; @@ -7407,14 +7425,14 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, size_t slot_len = (size_t)(cursor - slot_start); char slot_text[8]; if (slot_len == 0 || slot_len >= sizeof(slot_text)) { - snprintf(reply, 160, "Err - filter slot must be 1-%d", FLOOD_PACKET_FILTER_SLOTS); + snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots); return; } memcpy(slot_text, slot_start, slot_len); slot_text[slot_len] = 0; uint8_t slot; - if (!parseFloodFilterUnsigned(slot_text, FLOOD_PACKET_FILTER_SLOTS, slot) || slot == 0) { - snprintf(reply, 160, "Err - filter slot must be 1-%d", FLOOD_PACKET_FILTER_SLOTS); + if (!parseFloodFilterUnsigned(slot_text, flood_packet_filter_slots, slot) || slot == 0) { + snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots); return; } requested_slot = slot - 1; @@ -7829,7 +7847,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, int slot = requested_slot; if (slot < 0) { - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { // Keep the compatibility-owned row distinct from an ordinary rule with // identical match/action fields. Otherwise a generic, unnumbered set // would silently detach flood.channel.data from its own row. @@ -7845,7 +7863,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } } if (slot < 0) { - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { if (!flood_packet_filters[i].active) { slot = i; break; @@ -7876,7 +7894,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } #else void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_len) const { - if (index < 0 || index >= FLOOD_PACKET_FILTER_SLOTS || !flood_packet_filters[index].active) { + if (index < 0 || index >= flood_packet_filter_slots || !flood_packet_filters[index].active) { snprintf(reply, reply_len, "Err - empty filter slot"); return; } @@ -7909,8 +7927,8 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply) const { if (*selector == '.') selector = skipFloodFilterSpaces(selector + 1); if (*selector != 0) { uint8_t slot; - if (!parseFloodFilterUnsigned(selector, FLOOD_PACKET_FILTER_SLOTS, slot) || slot == 0) { - snprintf(reply, 160, "Err - filter slot must be 1-%d", FLOOD_PACKET_FILTER_SLOTS); + if (!parseFloodFilterUnsigned(selector, flood_packet_filter_slots, slot) || slot == 0) { + snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots); return; } formatFloodPacketFilterDetail(slot - 1, reply, 160); @@ -7920,7 +7938,7 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply) const { size_t used = (size_t)snprintf(reply, 160, ">"); int active_count = 0; bool truncated = false; - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { const auto& entry = flood_packet_filters[i]; if (!entry.active) continue; active_count++; @@ -7962,14 +7980,14 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, size_t slot_len = (size_t)(cursor - slot_start); char slot_text[8]; if (slot_len == 0 || slot_len >= sizeof(slot_text)) { - snprintf(reply, 160, "Err - filter slot must be 1-%d", FLOOD_PACKET_FILTER_SLOTS); + snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots); return; } memcpy(slot_text, slot_start, slot_len); slot_text[slot_len] = 0; uint8_t slot; - if (!parseFloodFilterUnsigned(slot_text, FLOOD_PACKET_FILTER_SLOTS, slot) || slot == 0) { - snprintf(reply, 160, "Err - filter slot must be 1-%d", FLOOD_PACKET_FILTER_SLOTS); + if (!parseFloodFilterUnsigned(slot_text, flood_packet_filter_slots, slot) || slot == 0) { + snprintf(reply, 160, "Err - filter slot must be 1-%d", flood_packet_filter_slots); return; } requested_slot = slot - 1; @@ -8081,7 +8099,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, int slot = requested_slot; if (slot < 0) { - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { const auto& entry = flood_packet_filters[i]; if (entry.active && entry.payload_type == payload_type && entry.min_hops == min_hops && entry.max_hops == max_hops @@ -8095,7 +8113,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply, } } if (slot < 0) { - for (int i = 0; i < FLOOD_PACKET_FILTER_SLOTS; i++) { + for (int i = 0; i < flood_packet_filter_slots; i++) { if (!flood_packet_filters[i].active) { slot = i; break; @@ -8137,7 +8155,7 @@ void MyMesh::deleteFloodPacketFilter(const char* args, char* reply) { if (!saveFloodPacketFilters(false, true)) { strcpy(reply, "Err - unable to save flood filter"); } else { - memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + memset(flood_packet_filters, 0, sizeof(FloodPacketFilterEntry) * flood_packet_filter_slots); #if MESH_ENABLE_FLOOD_RULE_ENGINE flood_channel_data_rule_slot = 0xFF; #endif @@ -8147,8 +8165,8 @@ void MyMesh::deleteFloodPacketFilter(const char* args, char* reply) { } uint8_t slot; - if (!parseFloodFilterUnsigned(selector, FLOOD_PACKET_FILTER_SLOTS, slot) || slot == 0) { - snprintf(reply, 160, "Err - use: del flood.filter.<1-%d>|all", FLOOD_PACKET_FILTER_SLOTS); + if (!parseFloodFilterUnsigned(selector, flood_packet_filter_slots, slot) || slot == 0) { + snprintf(reply, 160, "Err - use: del flood.filter.<1-%d>|all", flood_packet_filter_slots); return; } int index = slot - 1; @@ -12017,6 +12035,12 @@ void MyMesh::loop() { _cli.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()); +#endif } #if MESH_ENABLE_TELEMETRY_HISTORY diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index d14b75f7..4e7e99c6 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -462,7 +462,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks }; mutable FloodRetryBridgeState flood_retry_bridge_states[MAX_FLOOD_RETRY_SLOTS]; FloodRetryBridgeReachability flood_retry_bridge_reachability[FLOOD_RETRY_BRIDGE_BUCKETS + 1]; - FloodPacketFilterEntry flood_packet_filters[FLOOD_PACKET_FILTER_SLOTS]; + // The rule table is the single largest member of this object. It is heap + // allocated in the constructor (before setup(), while the heap is still + // unfragmented) so classic ESP32's small link-time static DRAM window does + // not have to hold it. flood_packet_filter_slots is the live capacity, and + // is 0 when the allocation failed: every loop below is bounded by it, so a + // zero-capacity node simply forwards without rule filtering. + FloodPacketFilterEntry* flood_packet_filters; + uint8_t flood_packet_filter_slots; uint8_t flood_packet_filter_blacklist_count; uint8_t flood_packet_filter_blacklist[FLOOD_PACKET_FILTER_BLACKLIST_MAX] [FLOOD_PACKET_FILTER_PATH_ID_SIZE]; diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 1d308aa8..37a496f9 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -24,6 +24,19 @@ #include #endif +#if defined(ESP32) && defined(CONFIG_BT_ENABLED) && !defined(BLE_PIN_CODE) +// A repeater has no Bluetooth transport, but the Arduino SDK is built with the +// BT controller enabled, so its memory stays reserved unless the application +// says otherwise. initArduino() calls esp_bt_controller_mem_release() when +// this weak hook returns false, handing that region to the heap. Note this +// only grows the *runtime heap*: the same reservation is also carved out of +// the linker's dram0_0_seg (0xdb5c on classic ESP32), and no runtime call can +// give those static bytes back. It is what makes the heap-allocated tables +// above comfortable, not a substitute for them. +extern "C" bool btInUse(); +extern "C" bool btInUse() { return false; } +#endif + StdRNG fast_rng; #if MAX_RECENT_REPEATERS > 0 #if defined(ESP32) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 6426b75f..28cbadc5 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -278,7 +278,10 @@ void Mesh::loop() { bool Mesh::hasPendingOtaApply() const { #if defined(ENABLE_OTA) && !defined(OTA_SEEDER_ONLY) - return ota::ota_ctx().apply_pending; + // A released dynamic context cannot hold a pending apply: the context is only + // handed back once apply_pending is clear. + const ota::OtaContext* oc = ota::ota_context_if_active(); + return oc && oc->apply_pending; #else return false; #endif @@ -328,6 +331,14 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { } } #if defined(ENABLE_OTA) +#if OTA_DYNAMIC_CONTEXT + // Nothing below can run without storage, and a released context holds no + // pending apply or egress. Bail before any ota_ctx() dereference. + if (!ota::ota_context_if_active()) { + _ota_temp_was_active = false; + return; + } +#endif #if !defined(OTA_SEEDER_ONLY) // Deferred apply-reboot: a verified `ota applydelta` approves the update but does NOT reboot inline, // so its "verified; applying" reply can be delivered first (over LoRa that reply is the operator's @@ -356,12 +367,6 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() { } } } -#endif -#if defined(OTA_SHARED_COMPANION_QUEUE) - if (!ota::ota_context_if_active()) { - _ota_temp_was_active = false; - return; - } #endif const bool ota_active = isTempRadioActive(); if (!ota_active) { diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index c571d2eb..a11728eb 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -640,7 +640,7 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) { c.last_timestamp = UINT32_MAX; } self_id.calcSharedSecret(c.shared_secret, pub_key); // recalculate shared secrets in case our private key changed - if (num_clients < MAX_CLIENTS) { + if (num_clients < capacity) { clients[num_clients++] = c; } else { full = true; @@ -803,7 +803,7 @@ bool ClientACL::clear() { const bool files_cleared = !_fs->exists("/s_contacts") && !_fs->exists("/s_contacts.tmp") && !_fs->exists("/s_contacts.bak"); - memset(clients, 0, sizeof(clients)); + if (clients) memset(clients, 0, sizeof(ClientInfo) * (size_t)capacity); num_clients = 0; return files_cleared; } @@ -828,7 +828,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { } ClientInfo* c; - if (num_clients < MAX_CLIENTS) { + if (num_clients < capacity) { c = &clients[num_clients++]; } else { if (oldest == NULL) return NULL; // every entry is protected diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index 38b3342a..45f77a29 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -3,6 +3,7 @@ #include // needed for PlatformIO #include #include +#include // std::nothrow (heap-allocated client table) #define PERM_ACL_ROLE_MASK 7 // lower 3 bits #define PERM_ACL_GUEST 0 @@ -60,14 +61,23 @@ struct ClientLoginReplayClampResult { class ClientACL { FILESYSTEM* _fs; - ClientInfo clients[MAX_CLIENTS]; + ClientInfo* clients; + int capacity; // 0 when the table could not be allocated int num_clients; bool login_replay_store_available; public: + // MAX_CLIENTS entries run to several kilobytes. Classic ESP32's link-time + // static DRAM window is much smaller than its runtime heap, so the table is + // allocated here instead of living in .bss. This constructor runs before + // setup(), while the heap is still unfragmented. A failed allocation leaves + // a zero-capacity ACL that refuses new clients rather than writing through + // a null table; putClient() returns NULL and callers already handle that. ClientACL() { _fs = NULL; - memset(clients, 0, sizeof(clients)); + clients = new (std::nothrow) ClientInfo[MAX_CLIENTS]; + capacity = clients ? MAX_CLIENTS : 0; + if (clients) memset(clients, 0, sizeof(ClientInfo) * (size_t)capacity); num_clients = 0; login_replay_store_available = false; } diff --git a/src/helpers/ota/OtaContext.cpp b/src/helpers/ota/OtaContext.cpp index 927af8fa..3513c114 100644 --- a/src/helpers/ota/OtaContext.cpp +++ b/src/helpers/ota/OtaContext.cpp @@ -1,10 +1,13 @@ #include "OtaContext.h" #include +#if OTA_DYNAMIC_CONTEXT && defined(OTA_HEAP_CONTEXT) + #include +#endif namespace mesh { namespace ota { -#if defined(OTA_SHARED_COMPANION_QUEUE) +#if OTA_DYNAMIC_CONTEXT namespace { OtaContext* active_context = nullptr; void* storage_owner = nullptr; @@ -23,6 +26,23 @@ uint8_t saved_autoinstall = OtaContext::AUTOINSTALL_OFF; uint8_t saved_hops = OTA_HOP_LIMIT_DEFAULT; uint16_t saved_checkpoint = OTA_CHECKPOINT_BLOCKS; uint16_t saved_advert = OTA_ADVERT_INTERVAL_MINS; + +#if defined(OTA_HEAP_CONTEXT) +// Default storage for roles with no borrowable workspace (repeaters, room +// servers). The context is several kilobytes; keeping it off .bss matters most +// on classic ESP32, whose static DRAM window is far smaller than its heap. +OtaContext* heap_context = nullptr; + +OtaContext* acquireHeapContext(void*) { + if (!heap_context) heap_context = new (std::nothrow) OtaContext(); + return heap_context; // nullptr on exhaustion; the caller reports and bails +} + +void releaseHeapContext(void*) { + delete heap_context; + heap_context = nullptr; +} +#endif } void ota_set_context_storage(void* owner, OtaContext* (*acquire)(void*), @@ -52,6 +72,12 @@ void ota_begin_context(uint32_t target, OtaSend send, void* ctx, bool ota_acquire_context(char* reply, size_t cap) { if (active_context) return true; +#if defined(OTA_HEAP_CONTEXT) + if (!acquire_storage) { // no owner registered one: fall back to the heap + acquire_storage = acquireHeapContext; + release_storage = releaseHeapContext; + } +#endif if (!acquire_storage || !release_storage || !saved_send) { if (reply && cap) snprintf(reply, cap, "ERR mOTA storage is not ready"); return false; @@ -59,7 +85,11 @@ bool ota_acquire_context(char* reply, size_t cap) { active_context = acquire_storage(storage_owner); if (!active_context) { if (reply && cap) snprintf(reply, cap, +#if defined(OTA_HEAP_CONTEXT) + "ERR mOTA is out of memory; retry when the node is less busy"); +#else "ERR mOTA needs 128 free queue slots; sync unread messages with an app first"); +#endif return false; } OtaContext& c = *active_context; diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 33d80b33..826c081e 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -11,6 +11,19 @@ #include "OtaFormat.h" #include "OtaSelf.h" // ota_self_firmware() - prefer self-describing EndF identity at begin() #include "OtaBlInfo.h" // bootloader OTA-apply capability marker (nRF52); cached after first read + +// Storage policy for the mOTA context. A "dynamic" context is created on demand +// and handed back once idle, so its multi-kilobyte workspace only occupies RAM +// while an OTA operation is actually in flight: +// OTA_SHARED_COMPANION_QUEUE - borrows the Companion's offline message queue +// OTA_HEAP_CONTEXT - allocates from the heap, failing softly +// Every other build keeps the plain .bss singleton. +#if defined(OTA_SHARED_COMPANION_QUEUE) || defined(OTA_HEAP_CONTEXT) + #define OTA_DYNAMIC_CONTEXT 1 +#else + #define OTA_DYNAMIC_CONTEXT 0 +#endif + #if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) #include "OtaStoreQspiNrf52.h" #elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) @@ -82,7 +95,7 @@ class FolderMotaStore; // pull destination over the seeder link (full type onl #endif struct OtaContext { -#if defined(OTA_SHARED_COMPANION_QUEUE) +#if OTA_DYNAMIC_CONTEXT // Release at a main-loop boundary, after callers finish using this context. bool release_when_idle = false; #endif @@ -409,7 +422,7 @@ struct OtaContext { return false; } folder_active = true; -#if defined(OTA_SHARED_COMPANION_QUEUE) +#if OTA_DYNAMIC_CONTEXT release_when_idle = false; #endif _folder_link = link; @@ -439,7 +452,7 @@ struct OtaContext { folder_active = false; _folder_link = FOLDER_LINK_NONE; _folder_source = nullptr; -#if defined(OTA_SHARED_COMPANION_QUEUE) +#if OTA_DYNAMIC_CONTEXT release_when_idle = true; #endif } @@ -641,6 +654,14 @@ uint8_t ota_hop_limit(); !defined(OTA_SEEDER_ONLY) || !defined(COMPANION_RADIO_FULL) #error "Shared mOTA queue storage requires an nRF52 or ESP32 Full source-only Companion" #endif +#if defined(OTA_HEAP_CONTEXT) +#error "OTA_HEAP_CONTEXT and OTA_SHARED_COMPANION_QUEUE both own the context storage" +#endif +#endif + +#if OTA_DYNAMIC_CONTEXT +// ota_ctx() is only valid while storage is held. Callers that can run before a +// successful ota_acquire_context() must gate on ota_context_if_active() first. void ota_set_context_storage(void* owner, OtaContext* (*acquire)(void*), void (*release)(void*)); void ota_release_context_if_idle(bool temporary_radio_active); diff --git a/variants/heltec_v2/platformio.ini b/variants/heltec_v2/platformio.ini index 198a1778..6c194b29 100644 --- a/variants/heltec_v2/platformio.ini +++ b/variants/heltec_v2/platformio.ini @@ -38,6 +38,7 @@ 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 2f5d94e6..ce863715 100644 --- a/variants/lilygo_tbeam_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_SX1262/platformio.ini @@ -171,6 +171,7 @@ 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 From 1257ea4dc522f92ae016d78d7cb202769321ad72 Mon Sep 17 00:00:00 2001 From: MUSTARDTIGERFPV Date: Tue, 8 Sep 2026 16:40:33 -0700 Subject: [PATCH 2/9] Free OtaContext-owned buffers when the context is torn down OtaContext owns serve_buf, serve_self_leaves and serve_self_proof as raw malloc'd pointers, and nothing freed them on teardown: self-serve only frees them when it re-allocates, and reset_session() does not touch them. That was harmless while the context was a permanent .bss singleton, but both dynamic storage modes destroy it between operations - OTA_HEAP_CONTEXT deletes it, and the Companion's borrowed queue runs ~OtaContext() in place. A self-serving node reaches release with both self-serve buffers populated, so every acquire/release cycle leaked them. Give OtaContext a destructor that releases what it owns, and delete the copy operations so the raw pointers cannot be double-freed. --- src/helpers/ota/OtaContext.h | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 826c081e..a2052593 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -144,6 +144,22 @@ struct OtaContext { // count (the manager's fixed 4 KiB scratch covers <=1024 blocks, about 2 MiB at the new default). uint8_t* serve_self_leaves = nullptr; uint8_t* serve_self_proof = nullptr; + + // These raw buffers are owned by the context and are otherwise only freed + // when self-serve re-allocates them. A dynamic-storage build destroys the + // context between operations (OTA_HEAP_CONTEXT deletes it; the Companion's + // borrowed queue runs ~OtaContext() in place), so teardown has to release + // them or every acquire/release cycle leaks. A self-serving node reaches + // release with both buffers populated, so this is the normal path, not an + // edge case. + ~OtaContext() { + releaseServeBuffer(); // no-op where serve_buf is a fixed array + free(serve_self_leaves); + free(serve_self_proof); + } + OtaContext() = default; + OtaContext(const OtaContext&) = delete; // would double-free above + OtaContext& operator=(const OtaContext&) = delete; uint8_t serve_self_manifest[MOTA_MFL]; // fixed-layout full+unsigned manifest-minus-leaves (197 B) ApplyState apply_st; // pending apply (P6) From c6d71b969afffc4f39d1a05b13c6656b20fde674 Mon Sep 17 00:00:00 2001 From: MUSTARDTIGERFPV Date: Tue, 8 Sep 2026 17:13:13 -0700 Subject: [PATCH 3/9] 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 From ae3ddcd215a61dc5ce78365f1ce2c28fe61164fb Mon Sep 17 00:00:00 2001 From: MUSTARDTIGERFPV Date: Tue, 8 Sep 2026 14:06:04 -0700 Subject: [PATCH 4/9] Add SX1276 wrapper for DAC-controlled external PA gain Some SX1276 boards do not drive the antenna from the radio. They feed an external power amplifier whose gain is set by an analog control voltage on the PA's gain input (APC/APC1/APC2/VAPC depending on the module), driven from an MCU DAC. The radio sits at a fixed drive level and all power control happens on that pin, so setOutputPower() alone does not describe what the board can do. DacPaSX1276Wrapper overrides applyCachedTxPower() and translates the requested dBm, measured at the antenna, into a gain-control code using a per-board calibration table. The table defines the legal range, so out-of-range requests are refused rather than saturated at the nearest entry. An optional max_dbm argument caps the board below its top entry for thermal or regulatory headroom, and can only ever reduce the ceiling. The gain write goes through a virtual so a PA driven by PWM or an external DAC can reuse the rest. --- src/helpers/radiolib/DacPaSX1276Wrapper.h | 134 ++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/helpers/radiolib/DacPaSX1276Wrapper.h diff --git a/src/helpers/radiolib/DacPaSX1276Wrapper.h b/src/helpers/radiolib/DacPaSX1276Wrapper.h new file mode 100644 index 00000000..bf8cea06 --- /dev/null +++ b/src/helpers/radiolib/DacPaSX1276Wrapper.h @@ -0,0 +1,134 @@ +#pragma once + +#include +#include +#include "CustomSX1276Wrapper.h" + +// TX power control for boards whose SX1276 does not drive the antenna +// directly, but feeds an external power amplifier whose gain is set by an +// analog control voltage rather than by the radio. +// +// Such a PA exposes a gain-control input - variously labelled APC, APC1/APC2, +// VAPC or VGA depending on the module - which is driven here from one of the +// MCU's DAC outputs. The radio is parked at a single fixed drive level and +// every power step is made by moving that control voltage, so the only thing +// this class overrides is applyCachedTxPower(). +// +// A board supplies a table mapping the power it wants, in dBm at the antenna, +// to the control code that produces it. That table is the board's calibration: +// it is the only thing that knows what the amplifier actually does, so it is +// also what defines the legal range. Requests outside it are refused rather +// than saturated at the nearest end. +// +// static const DacPaLevel LEVELS[] = { {10, 30}, {17, 50}, {30, 130} }; +// DacPaSX1276Wrapper radio_driver(radio, board, PIN_APC, LEVELS, 3); +// +// Entries must be ordered by ascending dBm. The default drive level of +2 dBm +// is the SX1276's floor on PA_BOOST (RegPaConfig OutputPower = 0), which suits +// an amplifier expecting a small constant input; pass drive_dbm to override it +// for a PA that wants more. +// +// The gain control is written through writeGainControl(), which uses the +// ESP32's DAC by default. A board driving its PA from a PWM pin or an external +// I2C DAC can subclass and override that one method. +// +// --------------------------------------------------------------------------- +// Deriving a table from an ExpressLRS hardware layout +// +// ExpressLRS transmitter modules use this arrangement widely (they call it +// POWER_OUTPUT_DACWRITE), so their published layouts are a convenient source +// of vendor-calibrated tables. The layout's "power_values" array is indexed by +// (PowerLevels_e - power_min), so with power_min = 0 the entries run: +// +// PWR_10mW PWR_25mW PWR_50mW PWR_100mW PWR_250mW PWR_500mW ... +// 10 dBm 14 dBm 17 dBm 20 dBm 24 dBm 27 dBm +// +// Those are the vendor's nominal labels, not measurements. Treat an entry as +// an index into the amplifier's behaviour until it has been on a power meter. +// --------------------------------------------------------------------------- + +struct DacPaLevel { + int8_t dbm; // power at the antenna, after the amplifier + uint8_t dac; // gain-control code that produces it +}; + +// SX1276 PA_BOOST output floor: Pout = 2 + OutputPower, so +2 dBm is +// OutputPower = 0. +#define DAC_PA_DEFAULT_DRIVE_DBM 2 + +// Sentinel for the max_dbm constructor argument: use the table's top entry. +#define DAC_PA_TABLE_MAX INT8_MAX + +class DacPaSX1276Wrapper : public CustomSX1276Wrapper { +public: + // max_dbm optionally caps the amplifier below its top table entry, for a + // deployment that should not use everything the hardware can reach (thermal + // headroom, or a regulatory limit lower than the PA's capability). It is + // only ever a reduction; it cannot raise the ceiling above the table. + DacPaSX1276Wrapper(CustomSX1276& radio, mesh::MainBoard& board, + uint8_t ctrl_pin, + const DacPaLevel* levels, uint8_t num_levels, + int8_t max_dbm = DAC_PA_TABLE_MAX, + int8_t drive_dbm = DAC_PA_DEFAULT_DRIVE_DBM) + : CustomSX1276Wrapper(radio, board), + _ctrl_pin(ctrl_pin), _levels(levels), _num_levels(num_levels), + _drive_dbm(drive_dbm) { + _min_dbm = levels[0].dbm; + _max_dbm = levels[num_levels - 1].dbm; + if (max_dbm < _max_dbm) _max_dbm = max_dbm; + if (_max_dbm < _min_dbm) _max_dbm = _min_dbm; + } + + // The supported range, taken from the table itself. The amplifier decides + // what this board can do, so nothing else has to be told separately. + int8_t minTxPowerDbm() const { return _min_dbm; } + int8_t maxTxPowerDbm() const { return _max_dbm; } + + // Park the radio at its drive level and set the amplifier to dbm. Call once, + // after the radio has started. A startup level outside the supported range + // is brought into it rather than left unset. + void beginPowerControl(int8_t dbm) { + ((CustomSX1276 *)_radio)->setOutputPower(_drive_dbm); + if (dbm < _min_dbm) dbm = _min_dbm; + if (dbm > _max_dbm) dbm = _max_dbm; + applyCachedTxPower(dbm); + } + +protected: + // Emit a gain-control code. Override for a PA driven by PWM or an external + // DAC instead of the MCU's own. + virtual void writeGainControl(uint8_t code) { + dacWrite(_ctrl_pin, code); + } + + // MeshCore asks for power in dBm at the antenna. The radio register is left + // where beginPowerControl() put it; only the amplifier moves. + // + // Out-of-range requests are refused rather than silently saturated. Without + // this the top table entry becomes the response to any large number, which + // on a high-power module is not a failure anyone wants to discover on air. + int16_t applyCachedTxPower(int8_t dbm) override { + if (dbm < _min_dbm || dbm > _max_dbm) { + return RADIOLIB_ERR_INVALID_OUTPUT_POWER; + } + writeGainControl(codeForDbm(dbm)); + return RADIOLIB_ERR_NONE; + } + +private: + // Highest level that does not exceed dbm. Callers have already range-checked. + uint8_t codeForDbm(int8_t dbm) const { + uint8_t code = _levels[0].dac; + for (uint8_t i = 0; i < _num_levels; i++) { + if (_levels[i].dbm <= dbm) code = _levels[i].dac; + } + return code; + } + + uint8_t _ctrl_pin; + const DacPaLevel* _levels; + uint8_t _num_levels; + int8_t _drive_dbm; + int8_t _min_dbm; + int8_t _max_dbm; +}; From 83371a10f3bfafe0f4df442d503015f619ae64fb Mon Sep 17 00:00:00 2001 From: MUSTARDTIGERFPV Date: Tue, 8 Sep 2026 14:06:04 -0700 Subject: [PATCH 5/9] Add shared board class for ExpressLRS ESP32 TX modules ExpressLRS transmitter modules share a common shape: powered from the handset bay or USB rather than a battery, a fan over the amplifier, and an ESP8285 "backpack" on a second UART that MeshCore has no use for. ELRSTxBoard handles those from optional defines (PIN_FAN_EN, PIN_BACKPACK_EN, PIN_BACKPACK_BOOT, MANUFACTURER_NAME) so a variant only has to supply pin numbers. It also returns DIO0 from getIRQGpio(), since the ESP32Board default is the SX126x pin. --- src/helpers/esp32/ELRSTxBoard.h | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/helpers/esp32/ELRSTxBoard.h diff --git a/src/helpers/esp32/ELRSTxBoard.h b/src/helpers/esp32/ELRSTxBoard.h new file mode 100644 index 00000000..e45a3ea2 --- /dev/null +++ b/src/helpers/esp32/ELRSTxBoard.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +// The common shape of an ExpressLRS ESP32 transmitter module: powered from the +// JR bay or USB rather than a battery, a fan over the amplifier, and an +// ESP8285 "backpack" hanging off a second UART. +// +// A variant supplies whichever of these its ExpressLRS hardware.json lists: +// +// MANUFACTURER_NAME name reported to clients +// PIN_FAN_EN hardware.json "misc_fan_en" (HIGH = on) +// PIN_BACKPACK_EN hardware.json "backpack_en" (HIGH = enabled) +// PIN_BACKPACK_BOOT hardware.json "backpack_boot" +// +// There is no battery divider on these boards, and ESP32Board already returns +// 0 from getBattMilliVolts() when PIN_VBAT_READ is undefined, so nothing here +// needs to override it. + +#ifndef MANUFACTURER_NAME + #define MANUFACTURER_NAME "ExpressLRS TX" +#endif + +class ELRSTxBoard : public ESP32Board { +public: + void begin() { + ESP32Board::begin(); + + #ifdef PIN_BACKPACK_EN + // MeshCore has no use for the backpack. Hold it off so it cannot chatter + // on the shared UART. + pinMode(PIN_BACKPACK_EN, OUTPUT); + digitalWrite(PIN_BACKPACK_EN, LOW); + #endif + #ifdef PIN_BACKPACK_BOOT + pinMode(PIN_BACKPACK_BOOT, INPUT); // usually a strapping pin, leave it + #endif + + #ifdef PIN_FAN_EN + // ExpressLRS only spins the fan above 250 mW, but MeshCore transmits for + // far longer than an ExpressLRS packet, so just leave it running. + pinMode(PIN_FAN_EN, OUTPUT); + digitalWrite(PIN_FAN_EN, HIGH); + #endif + } + + const char* getManufacturerName() const override { + return MANUFACTURER_NAME; + } + + uint32_t getIRQGpio() override { + return P_LORA_DIO_0; // SX127x signals RxDone/TxDone on DIO0 + } +}; From c430ac130ed132e759af64737719b2fd2fa59247 Mon Sep 17 00:00:00 2001 From: MUSTARDTIGERFPV Date: Tue, 8 Sep 2026 14:06:04 -0700 Subject: [PATCH 6/9] Add GEPRC LINKFLOW 900M TX variant An ExpressLRS 900 MHz transmitter module: ESP32-D0WDQ6, 4 MB flash, SX1276 driving an external PA through a DAC gain input on GPIO26. Pin values come from the ExpressLRS hardware layout for this target (Unified_ESP32_900_TX). The board brings out only RX_EN, so RadioLib's single-pin RF switch handling covers it. Note GPIO12 is both RX_EN and the MTDI flash-voltage strapping pin, so it must stay low through reset. Verified on hardware: boots, radio initialises, TX power changes take effect and are rejected outside the configured range, and the node repeats flood traffic. Uses dual_ota_1536k.csv, and 115200 for upload because the CH340 on this board corrupts transfers above 230400. --- variants/geprc_linkflow_900/platformio.ini | 136 +++++++++++++++++++++ variants/geprc_linkflow_900/target.cpp | 51 ++++++++ variants/geprc_linkflow_900/target.h | 17 +++ 3 files changed, 204 insertions(+) create mode 100644 variants/geprc_linkflow_900/platformio.ini create mode 100644 variants/geprc_linkflow_900/target.cpp create mode 100644 variants/geprc_linkflow_900/target.h diff --git a/variants/geprc_linkflow_900/platformio.ini b/variants/geprc_linkflow_900/platformio.ini new file mode 100644 index 00000000..400befa4 --- /dev/null +++ b/variants/geprc_linkflow_900/platformio.ini @@ -0,0 +1,136 @@ +; GEPRC LINKFLOW 900M TX - an ExpressLRS 900 MHz transmitter module. +; +; ESP32-D0WDQ6, 4 MB flash, SX1276 driving an external PA whose gain is set +; from a DAC pin. The board itself is a plain ExpressLRS TX module, so the +; two classes it uses are shared: +; +; helpers/esp32/ELRSTxBoard.h fan + backpack + no battery +; helpers/radiolib/DacPaSX1276Wrapper.h DAC-controlled amplifier gain +; +; Every pin below is the value from the ExpressLRS hardware layout for this +; module - ExpressLRS target "GEPRC LINKFLOW 900M TX", firmware +; Unified_ESP32_900_TX - read back out of this module's own flash and +; transcribed here. Two entries in that layout are deliberately unused: +; serial_rx/serial_tx (both GPIO13, the inverted half-duplex CRSF link to the +; handset) and the backpack UART on GPIO16/17. + +[GEPRC_Linkflow_900] +extends = esp32_base +board = esp32dev +board_build.partitions = variants/dual_ota_1536k.csv +build_flags = + ${esp32_base.build_flags} + -I variants/geprc_linkflow_900 + -D GEPRC_LINKFLOW_900 + -D MANUFACTURER_NAME='"GEPRC LINKFLOW 900M TX"' + + ; ---- radio ---- + -D RADIO_CLASS=CustomSX1276 + -D WRAPPER_CLASS=DacPaSX1276Wrapper + -D P_LORA_SCLK=18 + -D P_LORA_MISO=19 + -D P_LORA_MOSI=23 + -D P_LORA_NSS=5 + -D P_LORA_RESET=14 + -D P_LORA_DIO_0=4 + -D P_LORA_DIO_1=21 + -D SX127X_CURRENT_LIMIT=120 + + ; ---- RF switch ---- + ; The board only brings out RX_EN (hardware.json "power_rxen"); there is no + ; TX_EN pin. Leaving SX127X_TXEN undefined makes RadioLib drive this pin + ; HIGH for RX and LOW for TX, which is what a single-pin switch wants. + ; NOTE: GPIO12 is also the MTDI flash-voltage strapping pin, so it has to + ; stay low through reset. Do not add a pull-up here. + -D SX127X_RXEN=12 + + ; ---- frequency ---- + ; This module's own ExpressLRS options blob had "domain": 1, which is + ; FCC915 in FHSS.cpp (903.5 - 926.9 MHz). So it is a US 915 MHz unit, and + ; the 869.618 inherited from arduino_base is wrong for it. build_unflags + ; below drops the inherited one so this is the only definition. + -D LORA_FREQ=910.525 + + ; ---- TX power ---- + ; ExpressLRS layout "power_apc2". The dBm-to-DAC table lives in target.cpp. + -D PIN_PA_APC2=26 + + ; These numbers are dBm at the antenna, i.e. after the amplifier. The + ; SX1276 itself sits at a fixed +2 dBm; see DacPaSX1276Wrapper.h. + ; 17 dBm (50 mW) is the stock ExpressLRS "power_default" for this board. + ; + ; LORA_TX_POWER must stay within 2..17. CustomSX1276::std_init() passes it + ; straight to RadioLib's begin(), and PA_BOOST rejects anything outside that + ; range, which would fail radio_init(). It only sets the startup level; + ; MIN/MAX below are what bound the range at runtime, and those go through + ; the DAC, so they are free to be higher. + ; + ; MAX_LORA_TX_POWER is defined once and used twice: the CLI validates + ; against it, and target.cpp hands the same value to the wrapper, which + ; enforces it in applyCachedTxPower(). The wrapper also refuses anything + ; outside its own table regardless, so a board that omits these flags is + ; still bounded by what the amplifier table actually describes. + ; + ; The amplifier will do 33 dBm (2 W), but ExpressLRS only ever asks for that + ; in short packet bursts. MeshCore transmits for far longer, so the ceiling + ; here is one table entry lower. Raise it to 33 only with cooling you + ; trust, and check your local duty-cycle and EIRP limits first. + -D LORA_TX_POWER=17 + -D MIN_LORA_TX_POWER=10 + -D MAX_LORA_TX_POWER=30 + + ; ---- board ---- + -D PIN_FAN_EN=32 ; hardware.json "misc_fan_en" + -D PIN_BACKPACK_EN=25 ; hardware.json "backpack_en" + -D PIN_BACKPACK_BOOT=15 ; hardware.json "backpack_boot" + + ; WS2812 on GPIO27. hardware.json says GRB ordering, which is what + ; neopixelWrite() emits. + -D P_LORA_TX_NEOPIXEL_LED=27 + +; The CH340 on this board is not reliable above 230400 for flash access; +; 460800 and 921600 both corrupt mid-transfer. +upload_speed = 115200 +build_unflags = + -D LORA_FREQ=869.618 + ; esp32_base turns LoRa OTA (mOTA) on for every ESP32 target. Its context + ; is ~15.6 KiB of .bss - two 4 KiB Merkle proof scratch buffers and two + ; 2 KiB block buffers - resident whether or not the node ever serves an + ; image. Classic ESP32 cannot afford it. Dropping ENABLE_OTA compiles the + ; whole engine out (MyMesh.h only includes OtaContext.h when it is set). + -D ENABLE_OTA=1 + -D OTA_FLASH_STORE=1 + -D OTA_FOLDER_SERIAL +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/geprc_linkflow_900> + - +lib_deps = + ${esp32_base.lib_deps} + +[env:GEPRC_Linkflow_900_repeater] +extends = GEPRC_Linkflow_900 +build_flags = + ${GEPRC_Linkflow_900.build_flags} + -D ADVERT_NAME='"Linkflow Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + ; --- classic ESP32 DRAM strip --------------------------------------- + ; These are all fixed .bss tables, sized for a comfortable target and + ; allocated whether or not a single rule is ever configured. Stock cost on + ; this build was ~18.5 KiB of flood tables plus 9.6 KiB of client ACL. + ; The node still repeats; it just has almost no room for forwarding rules, + ; scope rewrites, group moderation, or logged-in clients. + -D MAX_CLIENTS=2 ; was 32, at ~300 B/entry + -D MESH_ENABLE_FLOOD_RULE_ENGINE=0 ; drops the forward-rule engine + -D FLOOD_PACKET_FILTER_SLOTS=1 ; was 63, at 200 B/entry + -D FLOOD_CHANNEL_SCOPE_SLOTS=1 ; ESP32 default is 255; also drives + ; DIRECT_SCOPE and SCOPE_REQUIRE + -D MESH_ENABLE_FLOOD_GROUP_MODERATION=0 ; drops text moderation + -D FLOOD_GROUP_MODERATION_SLOTS=1 ; was 16, at 124 B/entry +build_src_filter = ${GEPRC_Linkflow_900.build_src_filter} + +<../examples/simple_repeater> +lib_deps = + ${GEPRC_Linkflow_900.lib_deps} + ${esp32_ota.lib_deps} diff --git a/variants/geprc_linkflow_900/target.cpp b/variants/geprc_linkflow_900/target.cpp new file mode 100644 index 00000000..3522b167 --- /dev/null +++ b/variants/geprc_linkflow_900/target.cpp @@ -0,0 +1,51 @@ +#include +#include "target.h" + +ELRSTxBoard board; + +static SPIClass spi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_0, P_LORA_RESET, P_LORA_DIO_1, spi); + +// The "power_values" array from the ExpressLRS hardware layout for this +// module (ExpressLRS target "GEPRC LINKFLOW 900M TX", firmware +// Unified_ESP32_900_TX), paired with the PowerLevels_e step each index maps +// to. power_min is 0 on this board, so index 0 is PWR_10mW. +static const DacPaLevel POWER_LEVELS[] = { + { 10, 30 }, // 10 mW + { 14, 40 }, // 25 mW + { 17, 50 }, // 50 mW <- ExpressLRS "power_default" + { 20, 60 }, // 100 mW + { 24, 80 }, // 250 mW + { 27, 90 }, // 500 mW + { 30, 130 }, // 1000 mW + { 33, 225 }, // 2000 mW <- above MAX_LORA_TX_POWER, see platformio.ini +}; + +// MAX_LORA_TX_POWER is the single definition of this board's ceiling: the CLI +// validates against it, and the wrapper enforces it independently so a request +// that gets past the CLI cannot saturate the amplifier at its top table entry. +WRAPPER_CLASS radio_driver(radio, board, PIN_PA_APC2, + POWER_LEVELS, + sizeof(POWER_LEVELS) / sizeof(POWER_LEVELS[0]), + MAX_LORA_TX_POWER); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); +SensorManager sensors; + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + + if (!radio.std_init(&spi)) return false; + + // std_init() left the SX1276 at LORA_TX_POWER, which on this board is a + // post-amplifier number. Hand power control over to the amplifier instead. + radio_driver.beginPowerControl(LORA_TX_POWER); + return true; +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} diff --git a/variants/geprc_linkflow_900/target.h b/variants/geprc_linkflow_900/target.h new file mode 100644 index 00000000..6a602216 --- /dev/null +++ b/variants/geprc_linkflow_900/target.h @@ -0,0 +1,17 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include + +extern ELRSTxBoard board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern SensorManager sensors; + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); From 4bbcb5270a34b8b804afcba1f71abaec9712a9df Mon Sep 17 00:00:00 2001 From: MUSTARDTIGERFPV Date: Tue, 8 Sep 2026 14:25:37 -0700 Subject: [PATCH 7/9] Support boards that move the radio output alongside the PA gain Not every external-PA board parks its radio at a fixed drive level. Where the amplifier is driven somewhere other than its floor, the radio's own output steps with the requested power as well, and the board describes that with a second table paired to the first. ExpressLRS layouts call this "power_values2"; it is independent of dual-band operation, and appears on single-band modules such as the Radiomaster Bandit. An optional radio_dbm array supplies one radio output level per entry in the level table. When present the fixed drive level is unused, and each power change sets the radio before moving the amplifier so the PA is never asked to pass a level the radio has already exceeded. Values go to the SX1276 on the PA_BOOST path. A board wired to RFO_HF would need RadioLib's useRfo argument plumbed through, which this does not do. --- src/helpers/radiolib/DacPaSX1276Wrapper.h | 56 ++++++++++++++++++----- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/helpers/radiolib/DacPaSX1276Wrapper.h b/src/helpers/radiolib/DacPaSX1276Wrapper.h index bf8cea06..95832979 100644 --- a/src/helpers/radiolib/DacPaSX1276Wrapper.h +++ b/src/helpers/radiolib/DacPaSX1276Wrapper.h @@ -23,10 +23,28 @@ // static const DacPaLevel LEVELS[] = { {10, 30}, {17, 50}, {30, 130} }; // DacPaSX1276Wrapper radio_driver(radio, board, PIN_APC, LEVELS, 3); // -// Entries must be ordered by ascending dBm. The default drive level of +2 dBm -// is the SX1276's floor on PA_BOOST (RegPaConfig OutputPower = 0), which suits -// an amplifier expecting a small constant input; pass drive_dbm to override it -// for a PA that wants more. +// Entries must be ordered by ascending dBm. Nothing is assumed about the +// control codes themselves, so a board whose gain input runs backwards (a +// falling code for rising power) needs no special handling. +// +// The default drive level of +2 dBm is the SX1276's floor on PA_BOOST +// (RegPaConfig OutputPower = 0), which suits an amplifier expecting a small +// constant input; pass drive_dbm to override it for a PA that wants more. +// +// Not every board holds the radio still. Where the amplifier is driven +// somewhere other than its floor, the radio's own output moves with each step +// as well, and the board supplies a second table of radio output levels +// alongside the first. Pass it as radio_dbm and the fixed drive level is not +// used at all: +// +// static const DacPaLevel LEVELS[] = { {20, 165}, {24, 155}, {27, 142} }; +// static const int8_t RADIO_DBM[] = { 2, 6, 9 }; +// DacPaSX1276Wrapper radio_driver(radio, board, PIN_APC, LEVELS, 3, +// DAC_PA_TABLE_MAX, RADIO_DBM); +// +// radio_dbm must have one entry per level. Its values go to the SX1276 on the +// PA_BOOST path; a board wired to RFO_HF instead would need RadioLib's useRfo +// argument plumbed through, which this class does not do yet. // // The gain control is written through writeGainControl(), which uses the // ESP32's DAC by default. A board driving its PA from a PWM pin or an external @@ -69,10 +87,11 @@ public: uint8_t ctrl_pin, const DacPaLevel* levels, uint8_t num_levels, int8_t max_dbm = DAC_PA_TABLE_MAX, + const int8_t* radio_dbm = NULL, int8_t drive_dbm = DAC_PA_DEFAULT_DRIVE_DBM) : CustomSX1276Wrapper(radio, board), _ctrl_pin(ctrl_pin), _levels(levels), _num_levels(num_levels), - _drive_dbm(drive_dbm) { + _radio_dbm(radio_dbm), _drive_dbm(drive_dbm) { _min_dbm = levels[0].dbm; _max_dbm = levels[num_levels - 1].dbm; if (max_dbm < _max_dbm) _max_dbm = max_dbm; @@ -87,8 +106,13 @@ public: // Park the radio at its drive level and set the amplifier to dbm. Call once, // after the radio has started. A startup level outside the supported range // is brought into it rather than left unset. + // + // Boards carrying a radio_dbm table have no fixed drive level to park at; + // applyCachedTxPower() sets the radio for each step instead. void beginPowerControl(int8_t dbm) { - ((CustomSX1276 *)_radio)->setOutputPower(_drive_dbm); + if (_radio_dbm == NULL) { + ((CustomSX1276 *)_radio)->setOutputPower(_drive_dbm); + } if (dbm < _min_dbm) dbm = _min_dbm; if (dbm > _max_dbm) dbm = _max_dbm; applyCachedTxPower(dbm); @@ -111,23 +135,33 @@ protected: if (dbm < _min_dbm || dbm > _max_dbm) { return RADIOLIB_ERR_INVALID_OUTPUT_POWER; } - writeGainControl(codeForDbm(dbm)); + const uint8_t idx = indexForDbm(dbm); + if (_radio_dbm != NULL) { + // Move the radio first: on the way up this is the smaller of the two + // steps, so the amplifier is never asked to pass a level the radio has + // already exceeded. + const int16_t status = + ((CustomSX1276 *)_radio)->setOutputPower(_radio_dbm[idx]); + if (status != RADIOLIB_ERR_NONE) return status; + } + writeGainControl(_levels[idx].dac); return RADIOLIB_ERR_NONE; } private: // Highest level that does not exceed dbm. Callers have already range-checked. - uint8_t codeForDbm(int8_t dbm) const { - uint8_t code = _levels[0].dac; + uint8_t indexForDbm(int8_t dbm) const { + uint8_t idx = 0; for (uint8_t i = 0; i < _num_levels; i++) { - if (_levels[i].dbm <= dbm) code = _levels[i].dac; + if (_levels[i].dbm <= dbm) idx = i; } - return code; + return idx; } uint8_t _ctrl_pin; const DacPaLevel* _levels; uint8_t _num_levels; + const int8_t* _radio_dbm; int8_t _drive_dbm; int8_t _min_dbm; int8_t _max_dbm; From 36c5a6a350e0666cc4d49ac387a741e9e5e84750 Mon Sep 17 00:00:00 2001 From: MUSTARDTIGERFPV Date: Tue, 8 Sep 2026 14:29:14 -0700 Subject: [PATCH 8/9] Allow the radio to drive the amplifier from RFO instead of PA_BOOST The SX127x has two output pins and they accept different power ranges: RFO takes -4 to 15 dBm, PA_BOOST 2 to 17. RadioLib picks RFO on its own below 2 dBm, but above that it has no way to know which pin a board actually uses, so it defaults to PA_BOOST. A board wired to RFO_HF whose levels fall in the overlap therefore comes out of the wrong pin with no error anywhere. ExpressLRS layouts flag this as "radio_rfo_hf"; the Radiomaster Bandit is one, with radio levels of [2, 6, 9, 10] sitting squarely in the range both paths accept. force_rfo passes RadioLib's forceRfo through for both the fixed drive level and the per-step table. std_init()'s begin() always configures PA_BOOST, so beginPowerControl() is what moves an RFO board over; nothing transmits in between. --- src/helpers/radiolib/DacPaSX1276Wrapper.h | 32 ++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/helpers/radiolib/DacPaSX1276Wrapper.h b/src/helpers/radiolib/DacPaSX1276Wrapper.h index 95832979..ad5ee52c 100644 --- a/src/helpers/radiolib/DacPaSX1276Wrapper.h +++ b/src/helpers/radiolib/DacPaSX1276Wrapper.h @@ -42,9 +42,23 @@ // DacPaSX1276Wrapper radio_driver(radio, board, PIN_APC, LEVELS, 3, // DAC_PA_TABLE_MAX, RADIO_DBM); // -// radio_dbm must have one entry per level. Its values go to the SX1276 on the -// PA_BOOST path; a board wired to RFO_HF instead would need RadioLib's useRfo -// argument plumbed through, which this class does not do yet. +// radio_dbm must have one entry per level. +// +// Which output pin those values reach depends on how the board is wired. Set +// force_rfo for a board whose radio feeds the amplifier from RFO_HF rather +// than PA_BOOST (ExpressLRS layouts flag this as "radio_rfo_hf"). It matters +// because the two paths accept different ranges and RadioLib will not guess: +// +// RFO -4 .. 15 dBm +// PA_BOOST 2 .. 17 dBm, plus a special case at 20 +// +// RadioLib selects RFO on its own for anything below 2 dBm, so a board whose +// levels are all negative works either way. One sitting in the overlap - the +// Radiomaster Bandit's [2, 6, 9, 10], for instance - would silently come out +// of the wrong pin without this flag. +// +// drive_dbm's +2 default is the PA_BOOST floor; an RFO board should pass its +// own, since -4 is where that path bottoms out instead. // // The gain control is written through writeGainControl(), which uses the // ESP32's DAC by default. A board driving its PA from a PWM pin or an external @@ -88,10 +102,11 @@ public: const DacPaLevel* levels, uint8_t num_levels, int8_t max_dbm = DAC_PA_TABLE_MAX, const int8_t* radio_dbm = NULL, + bool force_rfo = false, int8_t drive_dbm = DAC_PA_DEFAULT_DRIVE_DBM) : CustomSX1276Wrapper(radio, board), _ctrl_pin(ctrl_pin), _levels(levels), _num_levels(num_levels), - _radio_dbm(radio_dbm), _drive_dbm(drive_dbm) { + _radio_dbm(radio_dbm), _force_rfo(force_rfo), _drive_dbm(drive_dbm) { _min_dbm = levels[0].dbm; _max_dbm = levels[num_levels - 1].dbm; if (max_dbm < _max_dbm) _max_dbm = max_dbm; @@ -109,9 +124,13 @@ public: // // Boards carrying a radio_dbm table have no fixed drive level to park at; // applyCachedTxPower() sets the radio for each step instead. + // + // This is also where an RFO board is corrected: std_init()'s begin() always + // configures PA_BOOST, so the first write from here moves it. Nothing + // transmits in between. void beginPowerControl(int8_t dbm) { if (_radio_dbm == NULL) { - ((CustomSX1276 *)_radio)->setOutputPower(_drive_dbm); + ((CustomSX1276 *)_radio)->setOutputPower(_drive_dbm, _force_rfo); } if (dbm < _min_dbm) dbm = _min_dbm; if (dbm > _max_dbm) dbm = _max_dbm; @@ -141,7 +160,7 @@ protected: // steps, so the amplifier is never asked to pass a level the radio has // already exceeded. const int16_t status = - ((CustomSX1276 *)_radio)->setOutputPower(_radio_dbm[idx]); + ((CustomSX1276 *)_radio)->setOutputPower(_radio_dbm[idx], _force_rfo); if (status != RADIOLIB_ERR_NONE) return status; } writeGainControl(_levels[idx].dac); @@ -162,6 +181,7 @@ private: const DacPaLevel* _levels; uint8_t _num_levels; const int8_t* _radio_dbm; + bool _force_rfo; int8_t _drive_dbm; int8_t _min_dbm; int8_t _max_dbm; From c002966c60a3cf3c5c798e61c361b98aeb39ffda Mon Sep 17 00:00:00 2001 From: MUSTARDTIGERFPV Date: Wed, 9 Sep 2026 01:36:09 -0700 Subject: [PATCH 9/9] Restore the full repeater feature set on the LINKFLOW 900 The variant carried a "classic ESP32 DRAM strip": MAX_CLIENTS cut from 32 to 2, the flood rule engine and group moderation compiled out, and the filter, scope and moderation tables cut to a single slot each. All of it existed to squeeze fixed .bss tables into classic ESP32's ~124 KiB static DRAM window. Those tables now live on the heap, so the strip buys nothing. Removing it also clears a real trap: with two client slots, once both held a protected manager (admin, region manager or filter manager) putClient() could no longer allocate, and every further admin login was refused with no reply to the operator. Static DRAM with the full feature set restored: 70,764 / 124,580 occupied, 53,816 free (8,192 required) --- variants/geprc_linkflow_900/platformio.ini | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/variants/geprc_linkflow_900/platformio.ini b/variants/geprc_linkflow_900/platformio.ini index 400befa4..7b06a8f4 100644 --- a/variants/geprc_linkflow_900/platformio.ini +++ b/variants/geprc_linkflow_900/platformio.ini @@ -116,19 +116,6 @@ build_flags = -D ADVERT_LON=0.0 -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 - ; --- classic ESP32 DRAM strip --------------------------------------- - ; These are all fixed .bss tables, sized for a comfortable target and - ; allocated whether or not a single rule is ever configured. Stock cost on - ; this build was ~18.5 KiB of flood tables plus 9.6 KiB of client ACL. - ; The node still repeats; it just has almost no room for forwarding rules, - ; scope rewrites, group moderation, or logged-in clients. - -D MAX_CLIENTS=2 ; was 32, at ~300 B/entry - -D MESH_ENABLE_FLOOD_RULE_ENGINE=0 ; drops the forward-rule engine - -D FLOOD_PACKET_FILTER_SLOTS=1 ; was 63, at 200 B/entry - -D FLOOD_CHANNEL_SCOPE_SLOTS=1 ; ESP32 default is 255; also drives - ; DIRECT_SCOPE and SCOPE_REQUIRE - -D MESH_ENABLE_FLOOD_GROUP_MODERATION=0 ; drops text moderation - -D FLOOD_GROUP_MODERATION_SLOTS=1 ; was 16, at 124 B/entry build_src_filter = ${GEPRC_Linkflow_900.build_src_filter} +<../examples/simple_repeater> lib_deps =