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:
commit
fa010196b1
26 files changed
+1072
-74
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user