Merge PR #7 with reviewed ExpressLRS power and memory fixes

Integrate ExpressLRS TX module support and its stacked ESP32 heap changes.
Use the approved Linkflow calibration (17-30 dBm), preserve the PA drive
and output path after radio recovery, and keep LoRa OTA enabled.

Preserve stored ACLs and filters on allocation failure, release owned
client/filter buffers, service heap OTA contexts on Companions, release
self-serving workspaces after TempRadio, and reset staged-resume state
when a context is released. Keep manual staging and active operations
alive. Account for all moved allocations in the runtime RAM gate.

Validation: 1,393 native cases; radio-power, heap-context, ACL persistence,
shared-queue transfer, display/inbox, radio receive, and memory regressions.
Firmware builds passed for Linkflow, Heltec V2 Companion, T-Beam MQTT
repeater, Heltec V4 R8 MQTT repeater, RAK4631 repeater, and Indicator Full.
Physical verification awaits access to the currently offline lab Pi.
This commit is contained in:
mikecarper committed 2026-09-10 22:06:16 -07:00
commit fa010196b1
26 files changed
+1072 -74

No files matched your search

+5 -1
View File
@@ -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
+9
View File
@@ -12,6 +12,9 @@
#include <helpers/StatsFormatHelper.h>
#include <helpers/UsbAsciiBinarySwitch.h>
#include <helpers/UsbLogging.h>
#if defined(ENABLE_OTA) && defined(OTA_HEAP_CONTEXT)
#include <helpers/ota/OtaContext.h>
#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
+70 -46
View File
@@ -4,6 +4,7 @@
#include <helpers/radiolib/RadioPowerLimits.h>
#include <helpers/radiolib/RxBoostedGainDefaults.h>
#include <algorithm>
#include <new> // std::nothrow (heap-allocated flood rule table)
#include <stdlib.h> // for qsort()
#include <helpers/CLICommandUtils.h>
#include <helpers/ClientACLCLI.h>
@@ -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<StaticPoolPacketManager*>(_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
+13 -1
View File
@@ -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();
+13
View File
@@ -24,6 +24,19 @@
#include <helpers/nrf52/EthernetCLI.h>
#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)
+6
View File
@@ -1,5 +1,8 @@
#include "MyMesh.h"
#include <helpers/UsbLogging.h>
#if defined(ENABLE_OTA)
#include <helpers/ota/OtaContext.h>
#endif
#include <helpers/FileRead.h>
#include <helpers/radiolib/RxBoostedGainDefaults.h>
#include <helpers/CLICommandUtils.h>
@@ -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 {
+6
View File
@@ -6,6 +6,9 @@
#include <helpers/LazyPersistence.h>
#include <helpers/radiolib/RXPowerSaving.h>
#include <helpers/UsbLogging.h>
#if defined(ENABLE_OTA)
#include <helpers/ota/OtaContext.h>
#endif
static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) {
uint8_t shift = failure_count < 5 ? failure_count : 5;
@@ -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 {
+1
View File
@@ -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
+13
View File
@@ -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}
+57
View File
@@ -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
+18 -7
View File
@@ -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) {
+5 -3
View File
@@ -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
+18 -2
View File
@@ -3,6 +3,7 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#include <helpers/IdentityStore.h>
#include <new> // 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();
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <Arduino.h>
#include <helpers/ESP32Board.h>
// 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
}
};
+40 -2
View File
@@ -1,10 +1,13 @@
#include "OtaContext.h"
#include <assert.h>
#if OTA_DYNAMIC_CONTEXT && defined(OTA_HEAP_CONTEXT)
#include <new>
#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.
+64 -6
View File
@@ -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
+187
View File
@@ -0,0 +1,187 @@
#pragma once
#include <Arduino.h>
#include <stdint.h>
#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;
};
+4 -3
View File
@@ -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<ClientInfo> 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);
}
+30 -1
View File
@@ -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");
}
+2 -2
View File
@@ -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__":
+117
View File
@@ -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("-<helpers/ota/>", 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 <cstdint>\n#include <cstddef>\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 <cassert>
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()
+19
View File
@@ -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")
+139
View File
@@ -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 <helpers/ota/OtaContext.h>
#include <cassert>
#include <new>
#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<uint8_t*>(malloc(64));
c.serve_self_proof = static_cast<uint8_t*>(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<FolderMotaStore*>(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()
+114
View File
@@ -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}
+50
View File
@@ -0,0 +1,50 @@
#include <Arduino.h>
#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
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <helpers/esp32/ELRSTxBoard.h>
#include <helpers/radiolib/DacPaSX1276Wrapper.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/SensorManager.h>
extern ELRSTxBoard board;
extern WRAPPER_CLASS radio_driver;
extern AutoDiscoverRTCClock rtc_clock;
extern SensorManager sensors;
bool radio_init();
mesh::LocalIdentity radio_new_identity();