From 0d42d3c8e1fd68d3d65d401e9d540c8a2adc8cd7 Mon Sep 17 00:00:00 2001 From: MUSTARDTIGERFPV Date: Tue, 8 Sep 2026 15:54:21 -0700 Subject: [PATCH] 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