diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index ad6af757..cf303d46 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -74,7 +74,11 @@ jobs: python3 -B test/test_sx126x_receive_mode.py - name: Verify ESP32 static DRAM budget - run: python3 -B test/test_esp32_dram.py + run: | + python3 -B test/test_esp32_dram.py + python3 -B test/test_elrs_power.py + python3 -B test/test_ota_heap_context.py + python3 -B test/test_client_acl_spiffs.py - name: Verify ESP32 USB sleep and G3 button wake run: python3 -B test/test_esp32_usb_sleep.py diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b7d05b69..a3def25f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -12,6 +12,9 @@ #include #include #include +#if defined(ENABLE_OTA) && defined(OTA_HEAP_CONTEXT) +#include +#endif #include "helpers/radiolib/RXPowerSaving.h" #include "helpers/radiolib/RxBoostedGainDefaults.h" #include "helpers/radiolib/CadTiming.h" @@ -8626,6 +8629,12 @@ void MyMesh::loop() { #endif #if defined(OTA_SHARED_COMPANION_QUEUE) mesh::ota::ota_release_context_if_idle(isTempRadioActive() || _temp_radio_set_at != 0); +#elif defined(ENABLE_OTA) && defined(OTA_HEAP_CONTEXT) + mesh::ota::ota_service_temp_radio_context(isTempRadioActive() +#if COMPANION_FEATURE_TEMP_RADIO + || _temp_radio_set_at != 0 +#endif + ); #endif BaseChatMesh::loop(); #ifdef COMPANION_MESH_CLOCK_SYNC diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 8eff6e99..b1bf1197 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 @@ -3330,6 +3331,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; @@ -3381,7 +3391,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)); + if (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)); @@ -5668,6 +5678,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 == 0) return; auto& entry = flood_packet_filters[0]; memset(&entry, 0, sizeof(entry)); entry.active = true; @@ -5680,6 +5693,7 @@ void MyMesh::seedDefaultFloodPacketFilters() { // Preserve the former channel-block default as a normal FPF7 rule. Channel // authentication limits this any-type row to GRP_TXT and GRP_DATA packets. + if (flood_packet_filter_slots < 2) return; auto& wardriving = flood_packet_filters[1]; memset(&wardriving, 0, sizeof(wardriving)); wardriving.active = true; @@ -5707,6 +5721,7 @@ static_assert(PUB_KEY_SIZE == FloodFilterPolicy::CHANNEL_KEY_256_LEN, "flood rule 256-bit key encoding changed"); bool MyMesh::loadFloodPacketFilters() { + if (flood_packet_filter_slots == 0) return false; if (_fs == NULL) { seedDefaultFloodPacketFilters(); return true; @@ -5714,7 +5729,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)); + if (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)); @@ -5738,7 +5753,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; @@ -6073,7 +6088,7 @@ bool MyMesh::loadFloodPacketFilters() { } file.close(); if (!success) { - memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + if (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)); @@ -6178,7 +6193,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; @@ -6231,7 +6246,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; @@ -6312,7 +6327,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; @@ -6438,7 +6453,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; @@ -6499,6 +6514,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) @@ -6521,7 +6539,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)); @@ -6644,7 +6662,8 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase, } #else bool MyMesh::loadFloodPacketFilters() { - memset(flood_packet_filters, 0, sizeof(flood_packet_filters)); + if (flood_packet_filter_slots == 0) return false; + if (flood_packet_filters) memset(flood_packet_filters, 0, sizeof(FloodPacketFilterEntry) * flood_packet_filter_slots); if (_fs == NULL) { seedDefaultFloodPacketFilters(); return true; @@ -6664,7 +6683,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; @@ -6729,7 +6748,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 && flood_packet_filters) memset(flood_packet_filters, 0, sizeof(FloodPacketFilterEntry) * flood_packet_filter_slots); return success; } @@ -6737,16 +6756,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; @@ -6863,7 +6884,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; } @@ -6871,7 +6892,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; } @@ -6882,14 +6903,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( @@ -6911,7 +6932,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( @@ -6929,7 +6950,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( @@ -6945,7 +6966,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, @@ -7057,7 +7078,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) { @@ -7099,7 +7120,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) @@ -7119,7 +7140,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; @@ -7146,7 +7167,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) { @@ -7176,7 +7197,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; } @@ -7337,8 +7358,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); @@ -7348,7 +7369,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++; @@ -7408,14 +7429,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; @@ -7830,7 +7851,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. @@ -7846,7 +7867,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; @@ -7877,7 +7898,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; } @@ -7910,8 +7931,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); @@ -7921,7 +7942,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++; @@ -7963,14 +7984,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; @@ -8082,7 +8103,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 @@ -8096,7 +8117,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; @@ -8138,7 +8159,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)); + if (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 @@ -8148,8 +8169,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; @@ -12021,6 +12042,9 @@ void MyMesh::loop() { _cli.loop(); processDeferredCliCommand(); servicePostMeshLoop(); +#if defined(ENABLE_OTA) && OTA_DYNAMIC_CONTEXT + mesh::ota::ota_service_temp_radio_context(isTempRadioActive()); +#endif } #if MESH_ENABLE_TELEMETRY_HISTORY diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 8c072eb0..03f003fe 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -466,7 +466,16 @@ 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]; + static_assert(sizeof(FloodPacketFilterEntry) <= (MESH_ENABLE_FLOOD_RULE_ENGINE ? 200 : 40), + "Update the flood-table runtime RAM budget in check_firmware_ram.py"); + // 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]; @@ -941,6 +950,9 @@ protected: public: MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); + ~MyMesh() { delete[] flood_packet_filters; } + MyMesh(const MyMesh&) = delete; + MyMesh& operator=(const MyMesh&) = delete; void begin(FILESYSTEM* fs); void sendNodeDiscoverReq(); diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index a73718a6..1e33d2fa 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/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 7d3d47b9..d6236cf2 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 @@ -2779,6 +2782,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 569ef5a1..6d4c7cb2 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -6,6 +6,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; @@ -1358,6 +1361,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 860cc14c..c6fd1006 100644 --- a/platformio.ini +++ b/platformio.ini @@ -85,6 +85,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/check_firmware_ram.py b/scripts/check_firmware_ram.py index 9449b451..2ee5232b 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -89,6 +89,17 @@ def requirements(platform, defines, target): parts["core_filesystems_sensors"] = 8192 # Packet bytes plus all three queue tables and allocation overhead. parts["radio_packet_pool"] = 5120 if companion else 10240 + # These tables moved out of .bss, so linker heap bounds now include their + # space. Count it as startup allocation instead; C++ assertions bind the + # per-entry bounds to the production structures. + if re.search(r"repeater|room_server|sensor", target, re.I) or "COMPANION_MESH_CLOCK_SYNC" in defines: + parts["client_table"] = integer(defines, "MAX_CLIENTS", 32) * 320 + 16 + if "repeater" in target.lower(): + engine = integer(defines, "MESH_ENABLE_FLOOD_RULE_ENGINE", int(platform != "STM32_PLATFORM")) + slots = integer(defines, "FLOOD_PACKET_FILTER_SLOTS", 63 if engine else 16) + parts["flood_filter_table"] = slots * (200 if engine else 40) + 16 + if "ENABLE_OTA" in defines and "OTA_HEAP_CONTEXT" in defines: + parts["ota_context"] = 16384 + 16 if display and display != "NullDisplayDriver": parts["display_pixels_and_driver"] = display_heap parts["screen_objects_and_history"] = 8192 if companion else 2048 @@ -113,6 +124,8 @@ def requirements(platform, defines, target): largest = max(display_heap, parts["radio_packet_pool"], 8192 if platform == "ESP32_PLATFORM" else 0) if "expanded_message_previews" in parts: largest = max(largest, 8192 + parts["expanded_message_previews"]) + for name in ("client_table", "flood_filter_table", "ota_context"): + largest = max(largest, parts.get(name, 0)) return {"required_heap_bytes": required, "required_contiguous_bytes": largest, "components": parts, "display": display, "full_companion": full} diff --git a/scripts/esp32_ota_heap_context.py b/scripts/esp32_ota_heap_context.py new file mode 100644 index 00000000..6b64a53a --- /dev/null +++ b/scripts/esp32_ota_heap_context.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Keep the idle mOTA workspace off classic ESP32's static DRAM. + +Classic ESP32 links into a dram0_0_seg of ~124 KiB: memory.ld reserves the BT +controller's 0xdb5c bytes off the 0x2c200 window before the application gets +any, and no runtime call gives those static bytes back. OtaContext is several +kilobytes of that budget, held for the life of the device even though a +repeater or room server is outside an OTA window essentially always. + +OTA_HEAP_CONTEXT switches OtaContext to the on-demand storage path, so it is +allocated when an OTA operation needs it and freed once idle. Applied here +rather than per-env so every classic ESP32 build gets it and none can drift. + +Scoped to build.mcu == "esp32" deliberately. S2/S3/C-series and nRF52 have no +equivalent static-DRAM ceiling, so there the .bss singleton is the better +trade: it guarantees the workspace is present, and OTA is a recovery path. + +This must be a build flag, not a header default. OTA_HEAP_CONTEXT has to hold +the same value in every translation unit that sees OtaContext.h - including +OtaContext.cpp, which defines the storage - and a header test on +CONFIG_IDF_TARGET_ESP32 would depend on whether that unit had already reached +sdkconfig.h. +""" + +import re + +Import("env") # noqa: F821 -- PlatformIO/SCons supplies Import + +# Both name the owner of the context storage, and OtaContext.h rejects the +# pair. OTA_SHARED_COMPANION_QUEUE arrives via PLATFORMIO_BUILD_FLAGS from +# build.sh's Full Companion recipes, which are nRF52/S3 today - but a future +# classic ESP32 Full Companion must lose this default, not fail to compile. +CONFLICTING = ("OTA_HEAP_CONTEXT", "OTA_SHARED_COMPANION_QUEUE") + + +def _already_defined(env): + for define in env.get("CPPDEFINES", []): + name = define[0] if isinstance(define, (list, tuple)) else define + if str(name) in CONFLICTING: + return True + # PLATFORMIO_BUILD_FLAGS reaches BUILD_FLAGS as raw text, which may not be + # parsed into CPPDEFINES yet when this pre-script runs. + flags = env.get("BUILD_FLAGS", []) + text = flags if isinstance(flags, str) else " ".join(map(str, flags)) + return bool(re.search(r"(?:^|\s)-D\s*(?:" + "|".join(CONFLICTING) + + r")(?=[=\s]|$)", text)) + + +def _apply(env): + if str(env.BoardConfig().get("build.mcu", "")).lower() != "esp32": + return + if _already_defined(env): + return + env.Append(CPPDEFINES=[("OTA_HEAP_CONTEXT", 1)]) + + +_apply(env) # noqa: F821 diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 6ee6d441..28faca0e 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -292,7 +292,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 @@ -343,6 +346,20 @@ 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; +#if !defined(OTA_SEEDER_ONLY) + // A later workspace has a fresh manager. Resume its persistent staging + // store again, and let that manager evaluate automatic installation. + _ota_resumed = false; + _ota_autoinstall_tried = false; +#endif + 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 @@ -371,12 +388,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..791a097d 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; @@ -691,6 +691,8 @@ bool ClientACL::authorizeLoginTimestamp( } bool ClientACL::save(FILESYSTEM* fs, bool (*filter)(ClientInfo*)) { + // A failed allocation is not an empty ACL. Preserve the stored managers. + if (capacity == 0 || fs == NULL) return false; _fs = fs; #if defined(NRF52_PLATFORM) mesh::AtomicFileWriter file(_fs, "/s_contacts"); @@ -803,7 +805,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 +830,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 877fa5c9..ec0e27b9 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 @@ -51,6 +52,9 @@ struct ClientInfo { #define MAX_CLIENTS 32 #endif +static_assert(sizeof(void*) != 4 || sizeof(ClientInfo) <= 320, + "Update the client-table runtime RAM budget in check_firmware_ram.py"); + struct ClientLoginReplayClampResult { uint16_t stored_matched; uint16_t stored_changed; @@ -60,17 +64,29 @@ 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; } + ~ClientACL() { delete[] clients; } + ClientACL(const ClientACL&) = delete; + ClientACL& operator=(const ClientACL&) = delete; void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id); bool save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL); bool clear(); 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 + } +}; diff --git a/src/helpers/ota/OtaContext.cpp b/src/helpers/ota/OtaContext.cpp index 927af8fa..e2357777 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; @@ -81,7 +111,15 @@ uint8_t ota_hop_limit() { void ota_release_context_if_idle(bool temporary_radio_active) { if (!active_context) return; OtaContext& c = *active_context; - if (c.folder_active || c.folder_dest || c.serving || c.apply_pending) return; + if (c.folder_active || c.folder_dest || c.apply_pending) return; +#if defined(OTA_HEAP_CONTEXT) + // Self-serving ends with the temporary radio window. It must not keep the + // heap workspace forever after the first announcement. Manual staging is + // different: preserve its bytes across separate CLI commands until reset. + if (c.serve_expected != 0) return; +#else + if (c.serving) return; +#endif if (temporary_radio_active && !c.release_when_idle) return; // No host source/destination remains. Discard pending transfer work before // the queue reuses these bytes, including dynamic discovery/diff buffers. diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 33d80b33..d07db40c 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 @@ -131,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) @@ -409,7 +438,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 +468,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 } @@ -626,10 +655,15 @@ private: #endif }; -OtaContext& ota_ctx(); // process-wide singleton +#if defined(OTA_HEAP_CONTEXT) +static_assert(sizeof(OtaContext) <= 16384, + "Update the heap OTA runtime RAM budget in check_firmware_ram.py"); +#endif -// On constrained source-only Companions, the context exists only while its -// queue-backed workspace is owned by mOTA. Other builds keep the singleton. +OtaContext& ota_ctx(); // process-wide context + +// Dynamic builds return null outside an acquired workspace. Static builds +// always return their process-wide context. OtaContext* ota_context_if_active(); bool ota_acquire_context(char* reply, size_t cap); void ota_begin_context(uint32_t target, OtaSend send, void* ctx, @@ -641,9 +675,33 @@ 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); + +// 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. Also used by heap-backed Companions. Shared-queue +// Companions must acquire only on explicit host demand instead. +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/src/helpers/radiolib/DacPaSX1276Wrapper.h b/src/helpers/radiolib/DacPaSX1276Wrapper.h new file mode 100644 index 00000000..a773ba05 --- /dev/null +++ b/src/helpers/radiolib/DacPaSX1276Wrapper.h @@ -0,0 +1,187 @@ +#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. 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. +// +// 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 +// 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, + 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), _force_rfo(force_rfo), _drive_dbm(drive_dbm) { + if (!levels || !num_levels) { + _min_dbm = 1; + _max_dbm = 0; // Empty range: every power request fails. + return; + } + _min_dbm = levels[0].dbm; + _max_dbm = levels[num_levels - 1].dbm; + if (max_dbm < _max_dbm) _max_dbm = max_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. + // + // 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. + bool beginPowerControl(int8_t dbm) { + if (dbm < _min_dbm) dbm = _min_dbm; + if (dbm > _max_dbm) dbm = _max_dbm; + return setTxPower(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. Restore both the radio + // drive and amplifier setting, including after a watchdog hard reset. + // + // 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; + } + const uint8_t idx = indexForDbm(dbm); + // A watchdog hard reset restores std_init()'s radio output, too. Reapply + // the fixed drive level/RFO selection as well as per-step radio levels; + // otherwise recovery can drive the PA at LORA_TX_POWER instead of +2 dBm. + const int16_t status = ((CustomSX1276 *)_radio)->setOutputPower( + _radio_dbm ? _radio_dbm[idx] : _drive_dbm, _force_rfo); + 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 indexForDbm(int8_t dbm) const { + uint8_t idx = 0; + for (uint8_t i = 0; i < _num_levels; i++) { + if (_levels[i].dbm <= dbm) idx = i; + } + return idx; + } + + uint8_t _ctrl_pin; + 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; +}; diff --git a/test/fixtures/client_acl_cli/test_client_acl_cli.cpp b/test/fixtures/client_acl_cli/test_client_acl_cli.cpp index eec7f660..1d3bdba6 100644 --- a/test/fixtures/client_acl_cli/test_client_acl_cli.cpp +++ b/test/fixtures/client_acl_cli/test_client_acl_cli.cpp @@ -160,14 +160,15 @@ static void listing_does_not_mutate_acl() { mesh::LocalIdentity self; acl.load(&fs, self); for (unsigned i = 0; i < 5; ++i) add(acl, i, uint8_t(i)); - const auto before = acl; + std::vector before; + for (int i = 0; i < acl.getNumClients(); ++i) before.push_back(*acl.getClientByIdx(i)); const auto writes = fs.bytes_written; for (const char* command : {"get acl", "get acl 2", "get acl 3", "get acl 0"}) { query(acl, command); } - CHECK(acl.getNumClients() == before.getNumClients()); + CHECK(acl.getNumClients() == int(before.size())); for (int i = 0; i < acl.getNumClients(); ++i) { - CHECK(memcmp(acl.getClientByIdx(i), before.getClientByIdx(i), sizeof(ClientInfo)) == 0); + CHECK(memcmp(acl.getClientByIdx(i), &before[i], sizeof(ClientInfo)) == 0); } CHECK(fs.bytes_written == writes); } diff --git a/test/fixtures/client_acl_spiffs/test_client_acl_spiffs.cpp b/test/fixtures/client_acl_spiffs/test_client_acl_spiffs.cpp index 10654927..81417501 100644 --- a/test/fixtures/client_acl_spiffs/test_client_acl_spiffs.cpp +++ b/test/fixtures/client_acl_spiffs/test_client_acl_spiffs.cpp @@ -10,6 +10,11 @@ std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #condition); std::exit(1); \ } } while (0) +static bool fail_client_allocation = false; +void* operator new[](std::size_t size, const std::nothrow_t&) noexcept { + return fail_client_allocation ? nullptr : ::operator new[](size); +} + static const uint8_t KEY[PUB_KEY_SIZE] = {0x12, 0x57, 0xae, 0xe5}; static const char* PRIMARY = mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH; static const char* TEMP = mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH; @@ -447,8 +452,32 @@ static void clamp_backup_cleanup_failure_does_not_mutate_live() { CHECK(fs.files == before && fs.bytes_written == 0 && client->last_timestamp == 1000); } +static void allocation_failure_preserves_saved_clients() { + FakeFilesystem fs; + { + ClientACL original; + original.load(&fs, SELF); + CHECK(original.putClient(mesh::Identity(KEY), PERM_ACL_ADMIN)); + CHECK(original.save(&fs)); + } + const auto saved = fs.files; + fail_client_allocation = true; + ClientACL unavailable; + fail_client_allocation = false; + unavailable.load(&fs, SELF); + CHECK(unavailable.getNumClients() == 0); + CHECK(!unavailable.putClient(mesh::Identity(SECOND_KEY), PERM_ACL_ADMIN)); + const auto writes = fs.bytes_written; + CHECK(!unavailable.save(&fs)); + CHECK(fs.files == saved && fs.bytes_written == writes); + ClientACL recovered; + recovered.load(&fs, SELF); + CHECK(recovered.getNumClients() == 1 && recovered.getClient(KEY, PUB_KEY_SIZE)); +} + int main() { const struct { const char* name; void (*run)(); } tests[] = { + {"allocation failure preserves clients", allocation_failure_preserves_saved_clients}, {"missing read differs from empty file", missing_read_is_not_empty_file}, {"first admin and monotonic retries", first_admin_and_retries}, {"reboot preserves ceiling", reboot_preserves_ceiling}, @@ -478,5 +507,5 @@ int main() { test.run(); std::printf("PASS: %s\n", test.name); } - std::puts("24 ClientACL SPIFFS checks passed"); + std::puts("25 ClientACL SPIFFS checks passed"); } diff --git a/test/test_client_acl_spiffs.py b/test/test_client_acl_spiffs.py index b291f6d5..68f8d15e 100644 --- a/test/test_client_acl_spiffs.py +++ b/test/test_client_acl_spiffs.py @@ -25,8 +25,8 @@ class ClientAclSpiffsTest(unittest.TestCase): self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) - self.assertIn("24 ClientACL SPIFFS checks passed", checked.stdout) - self.assertEqual(checked.stdout.count("PASS:"), 24) + self.assertIn("25 ClientACL SPIFFS checks passed", checked.stdout) + self.assertEqual(checked.stdout.count("PASS:"), 25) if __name__ == "__main__": diff --git a/test/test_elrs_power.py b/test/test_elrs_power.py new file mode 100644 index 00000000..dc9d7522 --- /dev/null +++ b/test/test_elrs_power.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Exercise the production DAC PA wrapper with a recording radio transport.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class ElrsPowerTest(unittest.TestCase): + def test_linkflow_uses_approved_calibration_and_keeps_ota(self): + target = (ROOT / "variants/geprc_linkflow_900/target.cpp").read_text() + levels = re.findall(r"\{\s*(\d+),\s*(\d+)\s*\}", target) + self.assertEqual(levels, [("17", "0"), ("20", "22"), ("24", "50"), + ("27", "75"), ("30", "130"), ("33", "225")]) + config = (ROOT / "variants/geprc_linkflow_900/platformio.ini").read_text() + self.assertIn("MIN_LORA_TX_POWER=17", config) + self.assertIn("MAX_LORA_TX_POWER=30", config) + self.assertNotIn("-", config) + unflags = config.split("build_unflags =", 1)[1].split("build_src_filter", 1)[0] + self.assertNotIn("ENABLE_OTA", unflags) + + def test_startup_recovery_limits_and_radio_errors(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) + (path / "Arduino.h").write_text("#include \n#include \ninline void dacWrite(uint8_t, uint8_t) {}\n") + (path / "CustomSX1276Wrapper.h").write_text(r''' +#pragma once +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_INVALID_OUTPUT_POWER -1 +namespace mesh { struct MainBoard {}; } +struct CustomSX1276 { + int power = 17, error = 0, calls = 0; + bool rfo = false; + int16_t setOutputPower(int8_t dbm, bool use_rfo) { + ++calls; + if (error) return error; + power = dbm; rfo = use_rfo; return 0; + } +}; +class CustomSX1276Wrapper { +protected: + CustomSX1276* _radio; + int8_t cached = 0; + virtual int16_t applyCachedTxPower(int8_t dbm) = 0; +public: + CustomSX1276Wrapper(CustomSX1276& radio, mesh::MainBoard&) : _radio(&radio) {} + bool setTxPower(int8_t dbm) { + if (applyCachedTxPower(dbm)) return false; + cached = dbm; return true; + } + bool recover() { + _radio->power = 17; _radio->rfo = false; + return applyCachedTxPower(cached) == 0; + } +}; +''') + # Preserve the production wrapper; substitute only its base transport. + (path / "DacPaSX1276Wrapper.h").write_text( + (ROOT / "src/helpers/radiolib/DacPaSX1276Wrapper.h").read_text()) + (path / "test.cpp").write_text(r''' +#include "DacPaSX1276Wrapper.h" +#include +struct Driver : DacPaSX1276Wrapper { + using DacPaSX1276Wrapper::DacPaSX1276Wrapper; + int gain = -1, writes = 0; + void writeGainControl(uint8_t code) override { gain = code; ++writes; } +}; +int main() { + mesh::MainBoard board; + CustomSX1276 radio; + const DacPaLevel levels[] = {{10,30}, {17,50}, {24,80}, {30,130}, {33,225}}; + Driver fixed(radio, board, 26, levels, 5, 30); + assert(fixed.beginPowerControl(17) && radio.power == 2 && fixed.gain == 50); + assert(fixed.setTxPower(30) && fixed.gain == 130); + assert(fixed.recover() && radio.power == 2 && fixed.gain == 130); + for (int dbm = -128; dbm <= 127; ++dbm) { + const int writes = fixed.writes, calls = radio.calls; + const bool valid = dbm >= 10 && dbm <= 30; + assert(fixed.setTxPower(dbm) == valid); + if (!valid) assert(fixed.writes == writes && radio.calls == calls); + } + assert(fixed.setTxPower(23) && fixed.gain == 50); + radio.error = -7; + const int writes = fixed.writes; + assert(!fixed.setTxPower(24) && fixed.writes == writes); + assert(!fixed.beginPowerControl(17) && fixed.writes == writes); + radio.error = 0; + const int8_t steps[] = {2,6,9,10,12}; + Driver stepped(radio, board, 26, levels, 5, 30, steps, true); + assert(stepped.beginPowerControl(24) && radio.power == 9 && radio.rfo); + assert(stepped.recover() && radio.power == 9 && radio.rfo); + Driver rfo(radio, board, 26, levels, 5, 30, nullptr, true, -4); + assert(rfo.beginPowerControl(17) && rfo.recover() && radio.power == -4 && radio.rfo); + Driver capped(radio, board, 26, levels, 5, 23); + assert(capped.beginPowerControl(33) && capped.gain == 50); + assert(!capped.setTxPower(24)); + Driver impossible(radio, board, 26, levels, 5, 9); + assert(!impossible.beginPowerControl(17) && impossible.writes == 0); + Driver empty(radio, board, 26, nullptr, 0); + assert(!empty.beginPowerControl(17) && empty.writes == 0); +} +''') + binary = path / "power.exe" + flags = [] if os.name == "nt" else ["-fsanitize=address,undefined"] + result = subprocess.run(["c++", "-std=c++17", *flags, "-I", temp, + str(path / "test.cpp"), "-o", str(binary)], + text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + subprocess.run([str(binary)], check=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 77e8ad1e..488d50a0 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -66,6 +66,25 @@ def esp_fixture(path, modern=False, fragmented=False): class FirmwareRamTest(unittest.TestCase): + def test_heap_tables_and_ota_remain_in_runtime_budget(self): + policy = ram.requirements("ESP32_PLATFORM", { + "ENABLE_OTA": 1, "OTA_HEAP_CONTEXT": 1, + }, "GEPRC_Linkflow_900_repeater") + self.assertEqual(policy["components"]["client_table"], 32 * 320 + 16) + self.assertEqual(policy["components"]["flood_filter_table"], 63 * 200 + 16) + self.assertEqual(policy["components"]["ota_context"], 16384 + 16) + self.assertGreaterEqual(policy["required_contiguous_bytes"], 16384 + 16) + reduced = ram.requirements("STM32_PLATFORM", { + "MAX_CLIENTS": 2, "FLOOD_PACKET_FILTER_SLOTS": 8, + }, "wio_repeater") + self.assertEqual(reduced["components"]["client_table"], 2 * 320 + 16) + self.assertEqual(reduced["components"]["flood_filter_table"], 8 * 40 + 16) + companion = ram.requirements("NRF52_PLATFORM", {}, "t114_companion_radio_ble") + self.assertNotIn("client_table", companion["components"]) + self.assertNotIn("flood_filter_table", companion["components"]) + sensor = ram.requirements("NRF52_PLATFORM", {}, "t114_sensor") + self.assertEqual(sensor["components"]["client_table"], 32 * 320 + 16) + def test_browser_terminal_reserves_internal_session_and_psram_aware_scrollback(self): defines = {"ENABLE_USB_INTERFACE": 1, "WIFI_SSID": "", "DISPLAY_CLASS": "SSD1306Display"} base = ram.requirements("ESP32_PLATFORM", {**defines, "WEBCONFIG_DISABLED": 1}, "v4_companion") diff --git a/test/test_ota_heap_context.py b/test/test_ota_heap_context.py new file mode 100644 index 00000000..a1620671 --- /dev/null +++ b/test/test_ota_heap_context.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Run the real heap-backed OTA context and its lifetime boundaries on host.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest +from test_t096_full_memory import method + +ROOT = Path(__file__).resolve().parents[1] + + +class OtaHeapTest(unittest.TestCase): + def test_build_policy_only_selects_classic_esp32_and_respects_storage_owner(self): + class Env(dict): + def BoardConfig(self): + return {"build.mcu": self["mcu"]} + + def Append(self, **values): + for key, value in values.items(): + self.setdefault(key, []).extend(value) + + script = (ROOT / "scripts/esp32_ota_heap_context.py").read_text() + for mcu in ("esp32", "esp32s3", "esp32s2", "esp32c3", "nrf52840"): + env = Env(mcu=mcu) + exec(script, {"env": env, "Import": lambda _: None}) + self.assertEqual(env.get("CPPDEFINES", []), + [("OTA_HEAP_CONTEXT", 1)] if mcu == "esp32" else []) + for flags in ("-DOTA_SHARED_COMPANION_QUEUE=1", ["-D", "OTA_SHARED_COMPANION_QUEUE=1"], + ["-DOTA_HEAP_CONTEXT=1"]): + env = Env(mcu="esp32", BUILD_FLAGS=flags) + exec(script, {"env": env, "Import": lambda _: None}) + self.assertNotIn("CPPDEFINES", env) + env = Env(mcu="esp32", CPPDEFINES=[("OTA_SHARED_COMPANION_QUEUE", 1)]) + exec(script, {"env": env, "Import": lambda _: None}) + self.assertEqual(env["CPPDEFINES"], [("OTA_SHARED_COMPANION_QUEUE", 1)]) + + def test_context_releases_self_serve_but_preserves_live_operations(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) + source = path / "test.cpp" + absent_context = method((ROOT / "src/Mesh.cpp").read_text(), + "if (!ota::ota_context_if_active())") + # Exercise the production absence guard with install support enabled, + # independently of the source-only storage used by this host fixture. + absent_context = absent_context.replace("#if !defined(OTA_SEEDER_ONLY)", "#if 1") + (path / "mesh_absence.h").write_text( + "namespace ota = mesh::ota;\nstruct MeshMaintenance {\n" + "bool _ota_temp_was_active = true, _ota_resumed = true, _ota_autoinstall_tried = true;\n" + "void service() {\n" + absent_context + "\n}\n};\n") + source.write_text(r''' +#include +#include +#include +#include "mesh_absence.h" +using namespace mesh::ota; +static bool fail_allocation = false; +void* operator new(std::size_t size, const std::nothrow_t&) noexcept { + return fail_allocation ? nullptr : ::operator new(size); +} +namespace mesh { namespace ota { +bool ota_self_firmware(SelfFwInfo& info) { info = SelfFwInfo(); return false; } +} } +static bool send(void*, const uint8_t*, uint16_t, bool) { return true; } +int main() { + MeshMaintenance maintenance; + maintenance.service(); + assert(!maintenance._ota_temp_was_active && !maintenance._ota_resumed + && !maintenance._ota_autoinstall_tried); + char reply[160] = {}; + assert(!ota_acquire_context(reply, sizeof(reply))); + ota_begin_context(123, send, nullptr, "test", nullptr); + fail_allocation = true; + assert(!ota_acquire_context(reply, sizeof(reply))); + assert(strstr(reply, "out of memory") && !ota_context_if_active()); + fail_allocation = false; + for (int cycle = 0; cycle < 16; ++cycle) { + ota_service_temp_radio_context(true); + assert(ota_context_if_active()); + auto& c = ota_ctx(); + c.manager.set_max_hops(7); + c.autoinstall = OtaContext::AUTOINSTALL_TRUSTED; + c.serving = true; + c.serve_self_leaves = static_cast(malloc(64)); + c.serve_self_proof = static_cast(malloc(64)); + assert(c.ensureServeBuffer()); + ota_service_temp_radio_context(true); + assert(ota_context_if_active() == &c); + ota_service_temp_radio_context(false); + assert(!ota_context_if_active()); + assert(ota_hop_limit() == 7); + } + assert(ota_acquire_context(reply, sizeof(reply))); + assert(ota_ctx().autoinstall == OtaContext::AUTOINSTALL_TRUSTED); + assert(ota_ctx().manager.max_hops() == 7); + ota_ctx().apply_pending = true; + ota_service_temp_radio_context(false); + assert(ota_context_if_active()); + ota_ctx().apply_pending = false; + ota_ctx().folder_active = true; + ota_service_temp_radio_context(false); + assert(ota_context_if_active()); + ota_ctx().folder_active = false; + ota_ctx().folder_dest = reinterpret_cast(1); + ota_service_temp_radio_context(false); + assert(ota_context_if_active()); + ota_ctx().folder_dest = nullptr; + // Manual staging is a series of CLI commands, even outside TempRadio. + ota_ctx().serve_expected = 100; + assert(ota_ctx().ensureServeBuffer()); + ota_ctx().serve_buf[0] = 0x42; + ota_service_temp_radio_context(false); + assert(ota_context_if_active() && ota_ctx().serve_buf[0] == 0x42); + ota_ctx().serve_expected = 0; + ota_service_temp_radio_context(false); + assert(!ota_context_if_active()); +} +''') + flags = [] if os.name == "nt" else ["-fsanitize=address,undefined"] + tinf = path / "tinf.o" + subprocess.run([shutil.which("cc") or "gcc", "-DENABLE_OTA=1", *flags, "-c", + str(ROOT / "src/helpers/ota/OtaTinf.c"), "-o", str(tinf)], check=True) + sources = ["OtaContext.cpp", "OtaManager.cpp", "OtaProtocol.cpp", + "MotaContainer.cpp", "MerkleTree.cpp", "OtaDeflate.cpp"] + binary = path / "heap.exe" + result = subprocess.run([ + "c++", "-std=c++17", *flags, "-DENABLE_OTA=1", + "-DOTA_HEAP_CONTEXT=1", "-DESP32_PLATFORM=1", "-DOTA_SEEDER_ONLY=1", + "-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"), + str(source), *[str(ROOT / "src/helpers/ota" / name) for name in sources], + str(ROOT / "src/Utils.cpp"), str(tinf), "-o", str(binary), + ], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + subprocess.run([str(binary)], check=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/geprc_linkflow_900/platformio.ini b/variants/geprc_linkflow_900/platformio.ini new file mode 100644 index 00000000..f9be0733 --- /dev/null +++ b/variants/geprc_linkflow_900/platformio.ini @@ -0,0 +1,114 @@ +; 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. The current approved power calibration is cited in +; target.cpp. 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=17 + -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 +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 +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..a9643ccd --- /dev/null +++ b/variants/geprc_linkflow_900/target.cpp @@ -0,0 +1,50 @@ +#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 2, so index 0 is PWR_50mW. Approved measured calibration: +// https://github.com/ExpressLRS/Targets/blob/504178dcfa469ee32f4290d6ae9ba02e2f2f365e/TX/GEPRC%20900%20Linkflow.json +// The older eight-step table overdrives the PA at its lower settings. +static const DacPaLevel POWER_LEVELS[] = { + { 17, 0 }, // 50 mW <- ExpressLRS minimum and default + { 20, 22 }, // 100 mW + { 24, 50 }, // 250 mW + { 27, 75 }, // 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. + return radio_driver.beginPowerControl(LORA_TX_POWER); +} + +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();