Merge remote-tracking branch 'origin/keymindCascade' into keymindCascade

This commit is contained in:
mikecarper committed 2026-10-27 14:11:07 -07:00
commit eab29a12cd
15 files changed
+2067 -166

No files matched your search

+7 -5
View File
@@ -886,11 +886,13 @@ send retries only that broker every 30 seconds; successful brokers keep their
normal status interval. Each slot has a stable, distinct client ID, including
when two slots point at the same broker.
G3 also places the SDK's two padded WebSocket buffers per initialized MQTT
client in PSRAM, saving 2,050 internal bytes per client when PSRAM allocation
succeeds. G3 repeaters move their complete scope tables and advert-limit
history there too. Capacities, saved formats, task stacks and DMA reserves
remain unchanged; safe internal fallback is retained.
PSRAM-enabled ESP32 builds also place the SDK's two padded WebSocket buffers
per initialized MQTT client in PSRAM, saving 2,050 internal bytes per client when PSRAM allocation
succeeds. ESP32-S3 repeaters with PSRAM move their complete scope tables and
advert-limit history there too, allocating after QSPI/OPI PSRAM initialization.
Capacities, saved formats, task stacks and DMA reserves remain unchanged;
safe internal fallback is retained. Classic ESP32 T-Beam keeps its existing
table storage while receiving the MQTT buffer change.
Outbox diagnostics are cached by the network owner, so `get mqtt.stats` does
not wait for an SDK mutex held by a network write. `rec:N/write` records a
+13 -6
View File
@@ -190,22 +190,29 @@ other tasks. Available linked internal heap cannot establish how much
DMA-capable heap remains or how it is fragmented after boot. Only runtime
observations can establish that.
G3 repeater builds also place their channel/scope tables and full advert-source
history in PSRAM. Their capacities and persisted formats remain unchanged.
ESP32-S3 repeater builds with PSRAM also place their channel/scope tables and
full advert-source history there. Their capacities and persisted formats
remain unchanged.
The RAM check still reserves internal fallback for both complete tables;
moving them out of BSS does not relax startup admission or the DMA reserve.
If neither heap can safely hold a complete table, startup reports the failed
allocation and stops rather than running with reduced filtering protection.
Other targets keep their existing storage. `MESH_REPEATER_PSRAM_TABLES=0`
restores inline storage for a custom G3 build.
Allocation occurs in `MyMesh::begin`, after Arduino initializes either QSPI or
OPI PSRAM. Global constructors retain only the owners' pointers. Other targets
keep their existing storage. `MESH_REPEATER_PSRAM_TABLES=0` restores inline
storage for a custom build. The checker resolves the actual board MCU so the
complete fallback cannot disappear from a non-G3 S3's budget.
G3's IDF 4 WebSocket wrapper also prefers PSRAM for its two padded 1,025-byte
SDK buffers per initialized MQTT client, including clients using plain MQTT.
The IDF 4 WebSocket wrapper on any PSRAM-enabled ESP32 build also prefers PSRAM
for its two padded 1,025-byte SDK buffers per initialized MQTT client, including
clients using plain MQTT.
Five initialized clients move 10,250 bytes of payload from internal RAM.
Transport objects, task stacks, DMA buffers and radio packet queues keep their
existing placement. The existing extra-byte overflow fix and guarded internal
fallback remain in force. These figures describe allocation placement, not a
measurement of running free heap or qualification of five simultaneous brokers.
See the [cross-board checks](../test-results/esp32_psram_storage_2026-10-27.md)
for covered families and the distinction between linked and physical results.
Maximum-load qualification still needs physical boot and sustained reconnect,
publish and configuration-change tests with the selected number of brokers,
@@ -0,0 +1,96 @@
<!-- meshcore-hosted-doc-link:start -->
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/test-results/esp32_psram_storage_2026-10-27/">View this page on MeshCore Docs</a>.</p>
<!-- meshcore-hosted-doc-link:end -->
# ESP32 PSRAM offload, 2026-10-27
V4, G2 Full and T-Deck Full each free **36,792 bytes of internal RAM** by moving
their scope tables and advert history to PSRAM. MQTT buffers can save another
**2,050 bytes per initialized client**. Features, capacities and saved formats
remain unchanged. No board was flashed during these checks.
The [measured evidence](esp32_psram_storage_2026-10-27.safe.json) binds source
hashes, matched builds, capabilities and the two existing packaging failures.
The paired measurements isolate these PSRAM changes on baseline `74c03cb30`.
Concurrent Companion PIN and startup-screen changes were integrated afterward;
a further G2 Full build at `6a5a04b1b` passes with the same RAM result and
15/15 capability checks. It fits its OTA slots at 2,125,736 application bytes.
## Enabled by default
ESP32-S3 repeater builds with `BOARD_HAS_PSRAM` move complete scope tables and
advert history to PSRAM. Existing explicit RTC/IRAM advert backends retain
priority. This covers these configured board families and their repeater aliases:
| Board families | PSRAM interface |
| --- | --- |
| Station G2/G3, T-Deck, T-ETH Elite, T-Beam 1W | OPI |
| V4 R8, RC32, E213, E290, T190 | OPI |
| W12, Wio Tracker L2, XIAO ESP32-S3/WIO, ThinkNode M7/M9 | OPI |
| Original SenseCAP Indicator | OPI |
| Standard V4, T3-S3 SX1262/SX1276, EoRa-S3 | QSPI |
| T-Beam S3 Supreme, Waveshare S3 LR1121, Indicator N16R2 | QSPI |
The IDF 4 WebSocket wrapper also prefers PSRAM on all PSRAM-enabled ESP32
builds that initialize MQTT clients, including companions, room servers and
classic T-Beam. It retains both 1,025-byte padded buffers per initialized
client, including plain MQTT clients. IDF 5 remains a passthrough.
Classic T-Beam keeps its established table placement. Its qualified Full
MQTT profiles already store all 17,920 advert-history bytes in separate
word-accessible IRAM; only 3,162 scope-table bytes remain. This extension
retains that backend and its cache-workaround flags. Boards without compiled
PSRAM support retain existing allocation paths.
## Startup and fallback
QSPI PSRAM initializes in `initArduino()`, after global constructors. The
tables now allocate in `MyMesh::begin`, before routing and persistence start,
on both QSPI and OPI boards. Constructors retain only pointers. Getters never
allocate or move storage, and return complete array references so `sizeof`,
saved bytes, rollback and const access remain correct.
Internal fallback must preserve the 16 KiB DMA reserve and hold each complete
table; otherwise startup reports failure and stops. The RAM check resolves
the actual board MCU and retains both full fallback allowances. Packet pools,
task stacks, ISR state and DMA buffers retain their existing placement.
`MESH_REPEATER_PSRAM_TABLES=0` restores inline table storage for custom builds.
## Matched Full builds
| Target | Internal heap before | Internal heap after | Gain | Capability checks |
| --- | ---: | ---: | ---: | ---: |
| V4 | 203,456 | 240,248 | 36,792 | 16/16 |
| G2 | 200,328 | 237,120 | 36,792 | 15/15 |
| T-Deck | 223,960 | 260,752 | 36,792 | 14/14 |
All three pass compilation, RAM admission, dual-slot OTA fit and artifact
checks with identical capability lists and no declared reductions. The RAM
model still reserves 36,828 bytes for complete internal fallback, so its
conservative admission margins remain similar. These are linked capacity
measurements, not running free-heap or maximum broker-load measurements.
Two packaging failures were reproduced before and after these changes:
- G2 standard exceeds its old 1,310,720-byte slot: linked program size falls
from 1,375,509 to 1,374,093 bytes but still fails. G2 Full passes separately.
- Classic T-Beam Full MQTT compiles and passes RAM admission, but fails its
existing dual-slot OTA fit check. This image was not qualified or flashed.
Builds used `build.sh build-firmware TARGET --full-exact` with
`--firmware-version v1.18.0.2-psram-test --profile cascade
--radio-preset usa-cascade-fixed --require-ota`; G2 legacy testing substituted
`--standard`. PlatformIO processes ran one at a time.
## Software coverage
- All 65 RAM-policy tests pass, including real-MCU selection and unchanged
fallback, reserve and contiguous-block thresholds.
- Scope storage, advert storage and existing capacity/RTC/IRAM tests pass
(23 methods), including constructor-no-allocation, QSPI startup, full
persisted-byte comparisons, rollback, lifetime and allocation failures.
- WebSocket tests pass three methods and 32 scenarios over seven platform
configurations, with padding, cleanup, reserve and IDF 4/5 coverage.
Relevant native storage/transport tests run under ASan/UBSan. Physical boot,
RF timing, persistence and sustained MQTT load still need hardware validation.
File diff suppressed because it is too large. Load diff
+85 -80
View File
@@ -3707,11 +3707,6 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
: mesh::Mesh(radio, ms, rng, rtc, *createObserverPacketManager(32), tables),
region_map(key_store), temp_map(key_store),
_cli(board, rtc, sensors, region_map, acl, &_prefs, this),
#if MESH_REPEATER_PSRAM_TABLES
flood_channel_scopes(flood_channel_storage.require("channel scope tables").scopes),
flood_channel_direct_scopes(flood_channel_storage.require("channel scope tables").direct),
flood_channel_scope_requirements(flood_channel_storage.require("channel scope tables").requirements),
#endif
telemetry(MAX_PACKET_PAYLOAD - 4),
discover_limiter(4, 120), // max 4 every 2 minutes
anon_limiter(4, 180) // max 4 every 3 minutes
@@ -3796,11 +3791,13 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
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));
#if !MESH_REPEATER_PSRAM_TABLES
memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes));
memset(flood_channel_direct_scopes, 0,
sizeof(flood_channel_direct_scopes));
memset(flood_channel_scope_requirements, 0,
sizeof(flood_channel_scope_requirements));
#endif
#if MESH_ENABLE_FLOOD_GROUP_MODERATION
memset(flood_group_moderation, 0, sizeof(flood_group_moderation));
#endif
@@ -3998,6 +3995,14 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
// OTA mesh-integration (receive/begin/loop) is centralized in mesh::Mesh - no per-example wiring.
void MyMesh::begin(FILESYSTEM *fs) {
#if MESH_REPEATER_PSRAM_TABLES
// initArduino() has now initialized either QSPI or OPI PSRAM. Allocate
// complete tables before any routing or persistence can obtain an address.
if (!flood_channel_storage.begin("channel scope tables")) abort();
#ifndef PORTABLE_MQTT_OBSERVER
if (!flood_advert_limiter.begin()) abort();
#endif
#endif
#if MESH_ENABLE_FLOOD_RULE_ENGINE
flood_filter_probability_seed = getRNG()->nextInt(0, UINT32_MAX);
#endif
@@ -6651,9 +6656,9 @@ bool MyMesh::loadFloodPacketFilters() {
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));
memset(flood_channel_direct_scopes, 0,
sizeof(flood_channel_direct_scopes));
memset(floodChannelScopes(), 0, sizeof(floodChannelScopes()));
memset(floodChannelDirectScopes(), 0,
sizeof(floodChannelDirectScopes()));
flood_packet_filter_blacklist_count = 0;
memset(flood_packet_filter_blacklist, 0,
sizeof(flood_packet_filter_blacklist));
@@ -6957,7 +6962,7 @@ bool MyMesh::loadFloodPacketFilters() {
size_t retained_scope_bytes = (size_t)retained_scope
* sizeof(FloodChannelScopeEntry);
success = success
&& file.read((uint8_t*)flood_channel_scopes, retained_scope_bytes)
&& file.read((uint8_t*)floodChannelScopes(), retained_scope_bytes)
== retained_scope_bytes;
FloodChannelScopeEntry discarded_scope;
for (uint16_t i = retained_scope; success && i < scope_count; i++) {
@@ -6981,13 +6986,13 @@ bool MyMesh::loadFloodPacketFilters() {
success = false;
}
if (success && i < retained_direct) {
memcpy(flood_channel_direct_scopes[i], direct_name,
memcpy(floodChannelDirectScopes()[i], direct_name,
sizeof(direct_name));
}
}
for (uint16_t i = 0; success && i < retained_scope; i++) {
auto& entry = flood_channel_scopes[i];
auto& entry = floodChannelScopes()[i];
uint8_t selector =
FloodFilterPolicy::channelScopeMatchSelectorValue(entry.selector);
bool direct_target =
@@ -6996,7 +7001,7 @@ bool MyMesh::loadFloodPacketFilters() {
memset(&entry, 0, sizeof(entry));
} else if (direct_target
&& (entry.target_id > retained_direct
|| flood_channel_direct_scopes[entry.target_id - 1][0] == 0)) {
|| floodChannelDirectScopes()[entry.target_id - 1][0] == 0)) {
success = false;
} else if (selector <= FLOOD_CHANNEL_SCOPE_OTHER_ANY) {
entry.channel_hash = 0;
@@ -7051,9 +7056,9 @@ bool MyMesh::loadFloodPacketFilters() {
file.close();
if (!success) {
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));
memset(floodChannelScopes(), 0, sizeof(floodChannelScopes()));
memset(floodChannelDirectScopes(), 0,
sizeof(floodChannelDirectScopes()));
flood_packet_filter_blacklist_count = 0;
memset(flood_packet_filter_blacklist, 0,
sizeof(flood_packet_filter_blacklist));
@@ -7577,12 +7582,12 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase,
uint8_t direct_count = 0;
if (!empty_scope_phase) {
for (uint16_t i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) {
if (flood_channel_scopes[i].target_id != 0) {
if (floodChannelScopes()[i].target_id != 0) {
scope_count = (uint8_t)(i + 1U);
}
}
for (uint16_t i = 0; i < FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS; i++) {
if (flood_channel_direct_scopes[i][0] != 0) {
if (floodChannelDirectScopes()[i][0] != 0) {
direct_count = (uint8_t)(i + 1U);
}
}
@@ -7594,12 +7599,12 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase,
sizeof(flood_channel_data_rule_max_hops))
&& writeExact(&scope_count, sizeof(scope_count));
if (success && scope_count != 0) {
success = writeExact(flood_channel_scopes,
success = writeExact(floodChannelScopes(),
scope_count * sizeof(FloodChannelScopeEntry));
}
success = success && writeExact(&direct_count, sizeof(direct_count));
if (success && direct_count != 0) {
success = writeExact(flood_channel_direct_scopes,
success = writeExact(floodChannelDirectScopes(),
direct_count
* FLOOD_PACKET_FILTER_SCOPE_NAME_LEN);
}
@@ -9304,9 +9309,9 @@ static const char* wildcardFloodChannelScopeName(uint8_t selector) {
bool MyMesh::loadFloodChannelScopes() {
static_assert(sizeof(FloodChannelScopeEntry) == 36,
"channel scope persistence requires 36-byte entries");
memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes));
memset(flood_channel_direct_scopes, 0,
sizeof(flood_channel_direct_scopes));
memset(floodChannelScopes(), 0, sizeof(floodChannelScopes()));
memset(floodChannelDirectScopes(), 0,
sizeof(floodChannelDirectScopes()));
if (_fs == NULL || !_fs->exists(FLOOD_CHANNEL_SCOPE_FILE)) return true;
File file = openFloodSettingsRead(_fs, FLOOD_CHANNEL_SCOPE_FILE);
if (!file) return false;
@@ -9323,7 +9328,7 @@ bool MyMesh::loadFloodChannelScopes() {
&& file.read(&count, sizeof(count)) == sizeof(count);
uint8_t retained = count < FLOOD_CHANNEL_SCOPE_SLOTS ? count : FLOOD_CHANNEL_SCOPE_SLOTS;
size_t retained_bytes = (size_t)retained * sizeof(FloodChannelScopeEntry);
success = success && file.read((uint8_t*)flood_channel_scopes, retained_bytes) == retained_bytes;
success = success && file.read((uint8_t*)floodChannelScopes(), retained_bytes) == retained_bytes;
FloodChannelScopeEntry discarded;
for (int i = retained; success && i < count; i++) {
success = file.read((uint8_t*)&discarded, sizeof(discarded)) == sizeof(discarded);
@@ -9338,10 +9343,10 @@ bool MyMesh::loadFloodChannelScopes() {
size_t retained_direct_bytes = (size_t)retained_direct
* FLOOD_PACKET_FILTER_SCOPE_NAME_LEN;
success = success
&& file.read((uint8_t*)flood_channel_direct_scopes,
&& file.read((uint8_t*)floodChannelDirectScopes(),
retained_direct_bytes) == retained_direct_bytes;
for (int i = 0; success && i < retained_direct; i++) {
char* name = flood_channel_direct_scopes[i];
char* name = floodChannelDirectScopes()[i];
if (name[0] != 0 && !isValidStoredFloodFilterScopeName(name)) {
success = false;
}
@@ -9349,7 +9354,7 @@ bool MyMesh::loadFloodChannelScopes() {
}
for (int i = 0; success && i < retained; i++) {
auto& entry = flood_channel_scopes[i];
auto& entry = floodChannelScopes()[i];
if ((version_1
&& (FloodFilterPolicy::scopeUsesSlowTiming(entry.selector)
|| FloodFilterPolicy::scopeRequiresPath(entry.selector)))
@@ -9370,7 +9375,7 @@ bool MyMesh::loadFloodChannelScopes() {
} else if (direct_target
&& (!version_5
|| entry.target_id > FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS
|| flood_channel_direct_scopes[entry.target_id - 1][0] == 0)) {
|| floodChannelDirectScopes()[entry.target_id - 1][0] == 0)) {
success = false;
break;
} else if (selector <= FLOOD_CHANNEL_SCOPE_OTHER_ANY) {
@@ -9390,9 +9395,9 @@ bool MyMesh::loadFloodChannelScopes() {
// A malformed table is inert; corrupt storage must never assign a scope.
if (!success) {
memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes));
memset(flood_channel_direct_scopes, 0,
sizeof(flood_channel_direct_scopes));
memset(floodChannelScopes(), 0, sizeof(floodChannelScopes()));
memset(floodChannelDirectScopes(), 0,
sizeof(floodChannelDirectScopes()));
}
return success;
}
@@ -9410,17 +9415,17 @@ bool MyMesh::saveFloodChannelScopes(bool empty_table) {
bool success = file.write(magic, sizeof(magic)) == sizeof(magic)
&& file.write(&count, sizeof(count)) == sizeof(count);
if (success && count != 0) {
success = file.write((const uint8_t*)flood_channel_scopes,
sizeof(flood_channel_scopes)) == sizeof(flood_channel_scopes);
success = file.write((const uint8_t*)floodChannelScopes(),
sizeof(floodChannelScopes())) == sizeof(floodChannelScopes());
}
uint8_t direct_count = empty_table ? 0 : FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS;
success = success
&& file.write(&direct_count, sizeof(direct_count))
== sizeof(direct_count);
if (success && direct_count != 0) {
success = file.write((const uint8_t*)flood_channel_direct_scopes,
sizeof(flood_channel_direct_scopes))
== sizeof(flood_channel_direct_scopes);
success = file.write((const uint8_t*)floodChannelDirectScopes(),
sizeof(floodChannelDirectScopes()))
== sizeof(floodChannelDirectScopes());
}
file.close();
if (!success || !_fs->rename(FLOOD_CHANNEL_SCOPE_TEMP_FILE, FLOOD_CHANNEL_SCOPE_FILE)) {
@@ -9432,8 +9437,8 @@ bool MyMesh::saveFloodChannelScopes(bool empty_table) {
}
void MyMesh::loadFloodChannelScopeRequirements() {
memset(flood_channel_scope_requirements, 0,
sizeof(flood_channel_scope_requirements));
memset(floodChannelScopeRequirements(), 0,
sizeof(floodChannelScopeRequirements()));
if (_fs == NULL || !_fs->exists(FLOOD_CHANNEL_SCOPE_REQUIRE_FILE)) return;
File file =
@@ -9450,7 +9455,7 @@ void MyMesh::loadFloodChannelScopeRequirements() {
size_t retained_bytes =
(size_t)retained * sizeof(FloodChannelScopeRequireEntry);
success = success
&& file.read((uint8_t*)flood_channel_scope_requirements, retained_bytes)
&& file.read((uint8_t*)floodChannelScopeRequirements(), retained_bytes)
== retained_bytes;
FloodChannelScopeRequireEntry discarded;
for (int i = retained; success && i < count; i++) {
@@ -9458,7 +9463,7 @@ void MyMesh::loadFloodChannelScopeRequirements() {
== sizeof(discarded);
}
for (int i = 0; success && i < retained; i++) {
auto& entry = flood_channel_scope_requirements[i];
auto& entry = floodChannelScopeRequirements()[i];
if (entry.key_len == 0) {
memset(&entry, 0, sizeof(entry));
} else if (entry.key_len == CIPHER_KEY_SIZE
@@ -9476,8 +9481,8 @@ void MyMesh::loadFloodChannelScopeRequirements() {
file.close();
if (!success) {
memset(flood_channel_scope_requirements, 0,
sizeof(flood_channel_scope_requirements));
memset(floodChannelScopeRequirements(), 0,
sizeof(floodChannelScopeRequirements()));
}
}
@@ -9500,9 +9505,9 @@ bool MyMesh::saveFloodChannelScopeRequirements(bool empty_table) {
}
} else if (success) {
success = file.write(
(const uint8_t*)flood_channel_scope_requirements,
sizeof(flood_channel_scope_requirements))
== sizeof(flood_channel_scope_requirements);
(const uint8_t*)floodChannelScopeRequirements(),
sizeof(floodChannelScopeRequirements()))
== sizeof(floodChannelScopeRequirements());
}
file.close();
if (!success || !_fs->rename(FLOOD_CHANNEL_SCOPE_REQUIRE_TEMP_FILE,
@@ -9528,7 +9533,7 @@ bool MyMesh::findFloodChannelScopeRequirementMatch(
% CIPHER_BLOCK_SIZE) == 0;
for (int i = 0; i < FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS; i++) {
const auto& entry = flood_channel_scope_requirements[i];
const auto& entry = floodChannelScopeRequirements()[i];
if (entry.key_len == 0) continue;
table_active = true;
if (!valid_layout || packet->payload[0] != entry.channel_hash) continue;
@@ -9558,7 +9563,7 @@ bool MyMesh::applyFloodChannelScopeTarget(mesh::Packet* packet,
bool direct_target =
FloodFilterPolicy::channelScopeUsesDirectTarget(entry.selector);
if (direct_target) {
target_name = flood_channel_direct_scopes[entry.target_id - 1];
target_name = floodChannelDirectScopes()[entry.target_id - 1];
deriveFloodFilterScopeKey(target_name, scope);
regionless_scope_set = true;
} else {
@@ -9644,7 +9649,7 @@ bool MyMesh::applyFloodChannelScope(mesh::Packet* packet, bool& fast_track,
uint8_t data[MAX_PACKET_PAYLOAD];
for (int path_pass = 1; path_pass >= 0; path_pass--) {
for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) {
const auto& entry = flood_channel_scopes[i];
const auto& entry = floodChannelScopes()[i];
if (entry.target_id == 0 || !isExactFloodChannelScopeSelector(entry.selector)
|| packet->payload[0] != entry.channel_hash
|| !matches_path_pass(entry.selector, path_pass != 0)) {
@@ -9665,7 +9670,7 @@ bool MyMesh::applyFloodChannelScope(mesh::Packet* packet, bool& fast_track,
uint8_t wildcard = wildcardFloodChannelScopeSelector(type);
for (int path_pass = 1; path_pass >= 0; path_pass--) {
for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) {
const auto& entry = flood_channel_scopes[i];
const auto& entry = floodChannelScopes()[i];
if (entry.target_id != 0
&& FloodFilterPolicy::channelScopeMatchSelectorValue(entry.selector)
== wildcard
@@ -9819,7 +9824,7 @@ void MyMesh::formatFloodChannelScopeDetail(int index, char* reply, size_t reply_
snprintf(reply, reply_len, "Err - scope slot must be 1-%d", FLOOD_CHANNEL_SCOPE_SLOTS);
return;
}
const auto& entry = flood_channel_scopes[index];
const auto& entry = floodChannelScopes()[index];
if (entry.target_id == 0) {
snprintf(reply, reply_len, "> %d empty", index + 1);
return;
@@ -9856,7 +9861,7 @@ void MyMesh::formatFloodChannelScopeDetail(int index, char* reply, size_t reply_
if (FloodFilterPolicy::channelScopeUsesDirectTarget(entry.selector)) {
snprintf(reply, reply_len, "> %d match=%s%s scope=%s%s",
index + 1, channel, path,
flood_channel_direct_scopes[entry.target_id - 1], timing);
floodChannelDirectScopes()[entry.target_id - 1], timing);
return;
}
@@ -9888,7 +9893,7 @@ void MyMesh::formatFloodChannelScopes(const char* args, char* reply) {
int active = 0;
for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) {
if (flood_channel_scopes[i].target_id != 0) active++;
if (floodChannelScopes()[i].target_id != 0) active++;
}
#if defined(STM32_PLATFORM)
snprintf(reply, 160, "> %d/%d",
@@ -10016,7 +10021,7 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) {
int matching_slot = -1;
int free_slot = -1;
for (int i = 0; i < FLOOD_CHANNEL_SCOPE_SLOTS; i++) {
const auto& entry = flood_channel_scopes[i];
const auto& entry = floodChannelScopes()[i];
if (entry.target_id == 0) {
if (free_slot < 0) free_slot = i;
} else if (FloodFilterPolicy::channelScopeMatchSelectorValue(
@@ -10042,7 +10047,7 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) {
if (direct_target) {
int reusable_direct_scope_slot = -1;
for (int i = 0; i < FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS; i++) {
if (strcmp(flood_channel_direct_scopes[i], direct_scope_name) == 0) {
if (strcmp(floodChannelDirectScopes()[i], direct_scope_name) == 0) {
direct_scope_slot = i;
break;
}
@@ -10050,7 +10055,7 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) {
bool referenced = false;
for (int row = 0; row < FLOOD_CHANNEL_SCOPE_SLOTS; row++) {
if (row == slot) continue;
const auto& entry = flood_channel_scopes[row];
const auto& entry = floodChannelScopes()[row];
if (entry.target_id == i + 1
&& FloodFilterPolicy::channelScopeUsesDirectTarget(
entry.selector)) {
@@ -10070,15 +10075,15 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) {
strcpy(reply, "Err - scope table full");
return;
}
if (strcmp(flood_channel_direct_scopes[direct_scope_slot],
if (strcmp(floodChannelDirectScopes()[direct_scope_slot],
direct_scope_name) != 0) {
changed_direct_scope_slot = direct_scope_slot;
memcpy(previous_direct_scope,
flood_channel_direct_scopes[direct_scope_slot],
floodChannelDirectScopes()[direct_scope_slot],
sizeof(previous_direct_scope));
StrHelper::strzcpy(flood_channel_direct_scopes[direct_scope_slot],
StrHelper::strzcpy(floodChannelDirectScopes()[direct_scope_slot],
direct_scope_name,
sizeof(flood_channel_direct_scopes[direct_scope_slot]));
sizeof(floodChannelDirectScopes()[direct_scope_slot]));
}
}
@@ -10086,8 +10091,8 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) {
FloodFilterPolicy::encodeChannelScopeTargetSelector(selector,
direct_target);
FloodChannelScopeEntry previous = flood_channel_scopes[slot];
auto& entry = flood_channel_scopes[slot];
FloodChannelScopeEntry previous = floodChannelScopes()[slot];
auto& entry = floodChannelScopes()[slot];
memset(&entry, 0, sizeof(entry));
entry.target_id = direct_target ? direct_scope_slot + 1 : region->id;
entry.selector =
@@ -10100,7 +10105,7 @@ void MyMesh::setFloodChannelScope(const char* args, char* reply) {
if (!saveFloodChannelScopes()) {
entry = previous;
if (changed_direct_scope_slot >= 0) {
memcpy(flood_channel_direct_scopes[changed_direct_scope_slot],
memcpy(floodChannelDirectScopes()[changed_direct_scope_slot],
previous_direct_scope, sizeof(previous_direct_scope));
}
strcpy(reply, "Err - save failed");
@@ -10115,9 +10120,9 @@ void MyMesh::deleteFloodChannelScope(const char* args, char* reply) {
if (!saveFloodChannelScopes(true)) {
strcpy(reply, "Err - save failed");
} else {
memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes));
memset(flood_channel_direct_scopes, 0,
sizeof(flood_channel_direct_scopes));
memset(floodChannelScopes(), 0, sizeof(floodChannelScopes()));
memset(floodChannelDirectScopes(), 0,
sizeof(floodChannelDirectScopes()));
strcpy(reply, "OK");
}
return;
@@ -10129,14 +10134,14 @@ void MyMesh::deleteFloodChannelScope(const char* args, char* reply) {
strcpy(reply, "Err - use: del flood.channel.scope.<n>|all");
return;
}
if (flood_channel_scopes[index].target_id == 0) {
if (floodChannelScopes()[index].target_id == 0) {
strcpy(reply, "Err - empty scope slot");
return;
}
FloodChannelScopeEntry previous = flood_channel_scopes[index];
memset(&flood_channel_scopes[index], 0, sizeof(flood_channel_scopes[index]));
FloodChannelScopeEntry previous = floodChannelScopes()[index];
memset(&floodChannelScopes()[index], 0, sizeof(floodChannelScopes()[index]));
if (!saveFloodChannelScopes()) {
flood_channel_scopes[index] = previous;
floodChannelScopes()[index] = previous;
strcpy(reply, "Err - save failed");
} else {
strcpy(reply, "OK");
@@ -10150,7 +10155,7 @@ void MyMesh::formatFloodChannelScopeRequirementDetail(
FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS);
return;
}
const auto& entry = flood_channel_scope_requirements[index];
const auto& entry = floodChannelScopeRequirements()[index];
if (entry.key_len == 0) {
snprintf(reply, reply_len, "> %d empty", index + 1);
return;
@@ -10184,7 +10189,7 @@ void MyMesh::formatFloodChannelScopeRequirements(const char* args,
int active = 0;
for (int i = 0; i < FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS; i++) {
if (flood_channel_scope_requirements[i].key_len != 0) active++;
if (floodChannelScopeRequirements()[i].key_len != 0) active++;
}
snprintf(reply, 160,
"> %d/%d active; listed=require allowed scope, others=bypass",
@@ -10221,7 +10226,7 @@ void MyMesh::setFloodChannelScopeRequirement(const char* args, char* reply) {
int matching_slot = -1;
int free_slot = -1;
for (int i = 0; i < FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS; i++) {
const auto& entry = flood_channel_scope_requirements[i];
const auto& entry = floodChannelScopeRequirements()[i];
if (entry.key_len == 0) {
if (free_slot < 0) free_slot = i;
} else if (entry.key_len == key_len
@@ -10239,8 +10244,8 @@ void MyMesh::setFloodChannelScopeRequirement(const char* args, char* reply) {
}
FloodChannelScopeRequireEntry previous =
flood_channel_scope_requirements[slot];
auto& entry = flood_channel_scope_requirements[slot];
floodChannelScopeRequirements()[slot];
auto& entry = floodChannelScopeRequirements()[slot];
memset(&entry, 0, sizeof(entry));
entry.key_len = key_len;
entry.channel_hash = hash_prefix[0];
@@ -10265,8 +10270,8 @@ void MyMesh::deleteFloodChannelScopeRequirement(const char* args,
if (!saveFloodChannelScopeRequirements(true)) {
strcpy(reply, "Err - save failed");
} else {
memset(flood_channel_scope_requirements, 0,
sizeof(flood_channel_scope_requirements));
memset(floodChannelScopeRequirements(), 0,
sizeof(floodChannelScopeRequirements()));
strcpy(reply, "OK");
}
return;
@@ -10278,17 +10283,17 @@ void MyMesh::deleteFloodChannelScopeRequirement(const char* args,
strcpy(reply, "Err - use: del flood.channel.scope.require.<n>|all");
return;
}
if (flood_channel_scope_requirements[index].key_len == 0) {
if (floodChannelScopeRequirements()[index].key_len == 0) {
strcpy(reply, "Err - empty require slot");
return;
}
FloodChannelScopeRequireEntry previous =
flood_channel_scope_requirements[index];
memset(&flood_channel_scope_requirements[index], 0,
sizeof(flood_channel_scope_requirements[index]));
floodChannelScopeRequirements()[index];
memset(&floodChannelScopeRequirements()[index], 0,
sizeof(floodChannelScopeRequirements()[index]));
if (!saveFloodChannelScopeRequirements()) {
flood_channel_scope_requirements[index] = previous;
floodChannelScopeRequirements()[index] = previous;
strcpy(reply, "Err - save failed");
} else {
strcpy(reply, "OK");
+49 -7
View File
@@ -560,8 +560,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
uint8_t flood_packet_filter_blacklist[FLOOD_PACKET_FILTER_BLACKLIST_MAX]
[FLOOD_PACKET_FILTER_PATH_ID_SIZE];
#if MESH_REPEATER_PSRAM_TABLES
// Keep real array references: persistence, resets and rollback use sizeof
// these complete tables, and routing retains addresses into them.
// Only the pointer exists during global construction. Array-reference
// accessors preserve sizeof for persistence, resets and rollback.
struct FloodChannelStorage {
FloodChannelScopeEntry scopes[FLOOD_CHANNEL_SCOPE_SLOTS];
char direct[FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS][FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
@@ -575,11 +575,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
+ FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS * 34U + 2U,
"Update the repeater PSRAM fallback budget in check_firmware_ram.py");
mesh::RepeaterPsramStorage<FloodChannelStorage> flood_channel_storage;
FloodChannelScopeEntry (&flood_channel_scopes)[FLOOD_CHANNEL_SCOPE_SLOTS];
char (&flood_channel_direct_scopes)[FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS]
[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
FloodChannelScopeRequireEntry
(&flood_channel_scope_requirements)[FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS];
#else
FloodChannelScopeEntry flood_channel_scopes[FLOOD_CHANNEL_SCOPE_SLOTS];
char flood_channel_direct_scopes[FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS]
@@ -587,6 +582,53 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
FloodChannelScopeRequireEntry
flood_channel_scope_requirements[FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS];
#endif
using FloodChannelScopes = FloodChannelScopeEntry[FLOOD_CHANNEL_SCOPE_SLOTS];
using FloodChannelDirectScopes = char[FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS]
[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
using FloodChannelScopeRequirements =
FloodChannelScopeRequireEntry[FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS];
FloodChannelScopes& floodChannelScopes() {
#if MESH_REPEATER_PSRAM_TABLES
return flood_channel_storage.require().scopes;
#else
return flood_channel_scopes;
#endif
}
const FloodChannelScopes& floodChannelScopes() const {
#if MESH_REPEATER_PSRAM_TABLES
return flood_channel_storage.require().scopes;
#else
return flood_channel_scopes;
#endif
}
FloodChannelDirectScopes& floodChannelDirectScopes() {
#if MESH_REPEATER_PSRAM_TABLES
return flood_channel_storage.require().direct;
#else
return flood_channel_direct_scopes;
#endif
}
const FloodChannelDirectScopes& floodChannelDirectScopes() const {
#if MESH_REPEATER_PSRAM_TABLES
return flood_channel_storage.require().direct;
#else
return flood_channel_direct_scopes;
#endif
}
FloodChannelScopeRequirements& floodChannelScopeRequirements() {
#if MESH_REPEATER_PSRAM_TABLES
return flood_channel_storage.require().requirements;
#else
return flood_channel_scope_requirements;
#endif
}
const FloodChannelScopeRequirements& floodChannelScopeRequirements() const {
#if MESH_REPEATER_PSRAM_TABLES
return flood_channel_storage.require().requirements;
#else
return flood_channel_scope_requirements;
#endif
}
#if MESH_ENABLE_FLOOD_GROUP_MODERATION
FloodGroupModerationEntry flood_group_moderation[FLOOD_GROUP_MODERATION_SLOTS];
#endif
+7 -2
View File
@@ -355,7 +355,7 @@ def requirements(platform, defines, target):
slots = integer(defines, "FLOOD_PACKET_FILTER_SLOTS", 63 if engine else 16)
parts["flood_filter_table"] = slots * (304 if engine else 40) + 16
psram_tables_default = int(platform == "ESP32_PLATFORM" and qualified_repeater
and "STATION_G3_ESP32" in defines
and integer(defines, "CONFIG_IDF_TARGET_ESP32S3", 0)
and "BOARD_HAS_PSRAM" in defines)
if platform == "ESP32_PLATFORM" and integer(
defines, "MESH_REPEATER_PSRAM_TABLES", psram_tables_default):
@@ -605,7 +605,12 @@ def heap_regions(elf, platform, mcu):
def check_firmware(elf_path, platform, mcu, defines, target, output=None):
elf = FirmwareElf(elf_path)
if platform == "ESP32_PLATFORM":
defines = {**defines, "MESH_RAM_DEFAULT_RECENT_REPEATERS": 256 if str(mcu).lower() == "esp32" else 2048}
# SDK target macros are absent from SCons CPPDEFINES. Resolve them
# from the actual board MCU so source-selected PSRAM tables cannot
# disappear from the fallback budget on a non-G3 ESP32-S3.
target_mcu = str(mcu).lower()
defines = {**defines, "CONFIG_IDF_TARGET_ESP32S3": int(target_mcu == "esp32s3"),
"MESH_RAM_DEFAULT_RECENT_REPEATERS": 256 if target_mcu == "esp32" else 2048}
policy = requirements(platform, defines, target)
regions = heap_regions(elf, platform, mcu)
available = sum(end - start for start, end, _ in regions)
+2 -2
View File
@@ -67,7 +67,7 @@
#include "esp_transport.h"
#include "esp_transport_ws.h"
#if defined(STATION_G3_ESP32) && defined(BOARD_HAS_PSRAM)
#if defined(BOARD_HAS_PSRAM)
#include "esp_heap_caps.h"
#include "MQTTConnectionAdmission.h"
#endif
@@ -109,7 +109,7 @@ esp_transport_handle_t __wrap_esp_transport_ws_init(esp_transport_handle_t paren
// The buffer is untouched at this point (allocated moments ago inside
// __real_esp_transport_ws_init), so a swap is safe.
const size_t bytes = CONFIG_WS_BUFFER_SIZE + 1;
#if defined(STATION_G3_ESP32) && defined(BOARD_HAS_PSRAM)
#if defined(BOARD_HAS_PSRAM)
// Upgrade request/response text is CPU-only. Retain this padded buffer
// until the SDK destroys the transport; its ordinary free accepts PSRAM.
char* padded = (char*)heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
+11 -4
View File
@@ -91,8 +91,8 @@ protected:
#else
FloodAdvertLimiter(Entry* entries, size_t capacity) : _entries(entries), _capacity(capacity) {}
#if MESH_REPEATER_PSRAM_TABLES
// The derived storage owner is initialized after this base. Bind its full
// table before reset() or any limiter operation can inspect the entries.
// Bind the complete table from begin(), after Arduino initializes PSRAM,
// before reset() or any limiter operation can inspect the entries.
void bindStorage(Entry* entries) {
if (!entries || reinterpret_cast<uintptr_t>(entries) % alignof(Entry)) abort();
_entries = entries;
@@ -211,18 +211,25 @@ public:
struct Storage { Entry entries[Capacity]; };
RepeaterPsramStorage<Storage> _storage;
public:
StaticFloodAdvertLimiter() : FloodAdvertLimiter(nullptr, Capacity) {
StaticFloodAdvertLimiter() : FloodAdvertLimiter(nullptr, Capacity) {}
bool begin() {
if (_storage.data()) return true;
// Exhaustion must stop startup rather than disabling advert protection
// or reducing its source/hash history. No full internal array is retained.
if (!_storage.begin("advert source history")) abort();
if (!_storage.begin("advert source history")) return false;
bindStorage(_storage.data()->entries);
reset();
return true;
}
#else
Entry _storage[Capacity];
public:
StaticFloodAdvertLimiter() : FloodAdvertLimiter(_storage, Capacity) { reset(); }
#endif
#if !MESH_REPEATER_PSRAM_TABLES || FLOOD_ADVERT_IRAM_WORD_STORAGE \
|| FLOOD_ADVERT_RTC_FAST_SLOTS || FLOOD_ADVERT_RTC_SLOW_SLOTS
bool begin() { return true; }
#endif
};
} // namespace mesh
+20 -9
View File
@@ -1,7 +1,12 @@
#pragma once
#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM)
#include <sdkconfig.h>
#endif
#ifndef MESH_REPEATER_PSRAM_TABLES
#if defined(STATION_G3_ESP32) && defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) \
#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) \
&& defined(CONFIG_IDF_TARGET_ESP32S3) && CONFIG_IDF_TARGET_ESP32S3 \
&& defined(MESH_CLIENT_REPEATER_ONLY) && MESH_CLIENT_REPEATER_ONLY
#define MESH_REPEATER_PSRAM_TABLES 1
#else
@@ -17,12 +22,14 @@
#if !defined(ESP32_PLATFORM) || !defined(BOARD_HAS_PSRAM)
#error "Repeater PSRAM tables require an ESP32 target with PSRAM support"
#endif
#if !defined(MESH_CLIENT_REPEATER_ONLY) || !MESH_CLIENT_REPEATER_ONLY
#error "Repeater PSRAM tables require the repeater startup path"
#endif
#include <sdkconfig.h>
#if !defined(CONFIG_IDF_TARGET_ESP32S3) || !CONFIG_IDF_TARGET_ESP32S3 \
|| !defined(CONFIG_SPIRAM_BOOT_INIT) || !CONFIG_SPIRAM_BOOT_INIT \
|| !defined(CONFIG_SPIRAM_USE_MALLOC) || !CONFIG_SPIRAM_USE_MALLOC
#error "Repeater PSRAM tables require ESP32-S3 with boot-initialized PSRAM heaps"
#error "Repeater PSRAM tables require ESP32-S3 with PSRAM heaps"
#endif
#include <esp_heap_caps.h>
@@ -34,8 +41,8 @@
namespace mesh {
// Own only CPU data here. ISR state, locks, task stacks and DMA buffers retain
// their existing internal storage. G3's pinned SDK initializes PSRAM-backed
// heaps before global constructors, so tables can keep their startup lifetime.
// their existing internal storage. Allocate from begin(), after initArduino()
// initializes both QSPI and OPI PSRAM. Global constructors own only a pointer.
template<typename T> class RepeaterPsramStorage {
static_assert(std::is_trivial<T>::value,
"Repeater PSRAM tables require zero-initialized trivial data");
@@ -67,10 +74,14 @@ public:
T* data() { return value_; }
const T* data() const { return value_; }
T& require(const char* label) {
// Used only while binding the owner's references during construction.
// Allocation never moves after pointers escape to routing or persistence.
if (!begin(label)) abort();
T& require() {
// Never allocate through a getter: initialization must precede routing
// and persistence, and allocation never moves after pointers escape.
if (!value_) abort();
return *value_;
}
const T& require() const {
if (!value_) abort();
return *value_;
}
};
+72 -11
View File
@@ -453,8 +453,8 @@ int main() {
sensor = ram.requirements("NRF52_PLATFORM", {}, "t114_sensor")
self.assertEqual(sensor["components"]["client_table"], 32 * 320 + 16)
def test_g3_psram_tables_reserve_full_internal_fallback_without_cutting_other_owners(self):
flags = {"STATION_G3_ESP32": 1, "BOARD_HAS_PSRAM": 1,
def test_s3_psram_tables_reserve_full_internal_fallback_without_cutting_other_owners(self):
flags = {"CONFIG_IDF_TARGET_ESP32S3": 1, "BOARD_HAS_PSRAM": 1,
"MESH_CLIENT_REPEATER_ONLY": 1, "WITH_MQTT_BRIDGE": 1,
"DISPLAY_CLASS": "SH1106Display", "ADMIN_PASSWORD": "test",
"ENABLE_OTA": 1, "WITH_UDP_BRIDGE": 1, "WITH_RS232_BRIDGE": "Serial2"}
@@ -475,11 +475,11 @@ int main() {
self.assertEqual(ram.requirements("ESP32_PLATFORM", {
**flags, "MESH_REPEATER_PSRAM_TABLES": 1}, target), selected)
def test_g3_psram_table_defaults_follow_board_psram_platform_and_source_role_gates(self):
flags = {"STATION_G3_ESP32": 1, "BOARD_HAS_PSRAM": 1,
def test_psram_table_defaults_follow_s3_psram_platform_and_source_role_gates(self):
flags = {"CONFIG_IDF_TARGET_ESP32S3": 1, "BOARD_HAS_PSRAM": 1,
"MESH_CLIENT_REPEATER_ONLY": 1}
cases = [
("ESP32_PLATFORM", {k: v for k, v in flags.items() if k != "STATION_G3_ESP32"},
("ESP32_PLATFORM", {k: v for k, v in flags.items() if k != "CONFIG_IDF_TARGET_ESP32S3"},
"other_s3_repeater"),
("ESP32_PLATFORM", {k: v for k, v in flags.items() if k != "BOARD_HAS_PSRAM"},
"Station_G3_ESP32_repeater"),
@@ -493,6 +493,10 @@ int main() {
("ESP32_PLATFORM", {**flags, "MESH_CLIENT_REPEATER_ONLY": 0},
"Station_G3_ESP32_room_server"),
]
for mcu in ("ESP32", "ESP32S2", "ESP32C3", "ESP32C6"):
cases.append(("ESP32_PLATFORM", {
**flags, "CONFIG_IDF_TARGET_ESP32S3": 0,
"CONFIG_IDF_TARGET_" + mcu: 1}, "other_repeater"))
for platform in ("NRF52_PLATFORM", "RP2040_PLATFORM", "STM32_PLATFORM"):
cases.extend((platform, {**flags, **extra}, "other_repeater")
for extra in ({}, {"MESH_REPEATER_PSRAM_TABLES": 1}))
@@ -508,6 +512,30 @@ int main() {
self.assertIn("repeater_scope_storage_fallback", renamed["components"])
self.assertIn("repeater_advert_storage_fallback", renamed["components"])
def test_psram_repeater_table_defaults_cover_opi_and_qspi_families_without_a_g3_marker(self):
flags = {"BOARD_HAS_PSRAM": 1, "MESH_CLIENT_REPEATER_ONLY": 1,
"CONFIG_IDF_TARGET_ESP32S3": 1, "CONFIG_SPIRAM_USE_MALLOC": 1}
families = (
("LilyGo_TDeck_repeater", {"CONFIG_SPIRAM_MODE_OCT": 1,
"CONFIG_SPIRAM_BOOT_INIT": 1}),
("Station_G2_repeater", {"CONFIG_SPIRAM_MODE_OCT": 1,
"CONFIG_SPIRAM_BOOT_INIT": 1}),
("heltec_v4_repeater", {"CONFIG_SPIRAM_MODE_QUAD": 1}),
("SenseCapIndicator-LoRa-N16R2_repeater", {"CONFIG_SPIRAM_MODE_QUAD": 1}),
)
for target, extra in families:
with self.subTest(target=target):
defines = {**flags, **extra}
self.assertNotIn("STATION_G3_ESP32", defines)
selected = ram.requirements("ESP32_PLATFORM", defines, target)
original = ram.requirements("ESP32_PLATFORM", {
**defines, "MESH_REPEATER_PSRAM_TABLES": 0}, target)
self.assertEqual(selected["components"], {
**original["components"], "repeater_scope_storage_fallback": 18892,
"repeater_advert_storage_fallback": 17936})
self.assertEqual(selected["required_heap_bytes"] - original["required_heap_bytes"],
36828)
def test_explicit_psram_repeater_table_opt_in_and_capacity_overrides_are_fully_budgeted(self):
flags = {"BOARD_HAS_PSRAM": 1, "MESH_CLIENT_REPEATER_ONLY": 1,
"MESH_REPEATER_PSRAM_TABLES": 1}
@@ -537,7 +565,7 @@ int main() {
original["required_heap_bytes"] + scope + advert)
def test_psram_advert_budget_excludes_portable_iram_and_rtc_storage_only(self):
flags = {"STATION_G3_ESP32": 1, "BOARD_HAS_PSRAM": 1,
flags = {"CONFIG_IDF_TARGET_ESP32S3": 1, "BOARD_HAS_PSRAM": 1,
"MESH_CLIENT_REPEATER_ONLY": 1}
target = "Station_G3_ESP32_repeater"
original = ram.requirements("ESP32_PLATFORM", flags, target)
@@ -560,7 +588,7 @@ int main() {
**flags, **disabled_backends}, target), original)
def test_each_psram_table_fallback_can_set_the_contiguous_allocation_bound(self):
flags = {"STATION_G3_ESP32": 1, "BOARD_HAS_PSRAM": 1,
flags = {"CONFIG_IDF_TARGET_ESP32S3": 1, "BOARD_HAS_PSRAM": 1,
"MESH_CLIENT_REPEATER_ONLY": 1, "MAX_CLIENTS": 1,
"FLOOD_PACKET_FILTER_SLOTS": 1, "MAX_RECENT_REPEATERS": 0}
for extra, name in (({}, "repeater_scope_storage_fallback"),
@@ -571,13 +599,14 @@ int main() {
self.assertEqual(policy["required_contiguous_bytes"], policy["components"][name])
self.assertGreater(policy["required_contiguous_bytes"], 16384)
def test_g3_psram_fallback_elf_gate_rejects_old_budget_and_enforces_exact_total(self):
flags = {"STATION_G3_ESP32": 1, "BOARD_HAS_PSRAM": 1,
def test_s3_psram_fallback_elf_gate_rejects_old_budget_and_enforces_exact_total(self):
flags = {"BOARD_HAS_PSRAM": 1,
"MESH_CLIENT_REPEATER_ONLY": 1, "WITH_MQTT_BRIDGE": 1}
target = "Station_G3_ESP32_repeater"
target = "heltec_v4_repeater"
previous = {**flags, "MESH_REPEATER_PSRAM_TABLES": 0}
old = ram.requirements("ESP32_PLATFORM", previous, target)["required_heap_bytes"]
required = ram.requirements("ESP32_PLATFORM", flags, target)["required_heap_bytes"]
required = ram.requirements("ESP32_PLATFORM", {
**flags, "CONFIG_IDF_TARGET_ESP32S3": 1}, target)["required_heap_bytes"]
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "firmware.elf"
report = Path(temporary) / "firmware.memory.json"
@@ -596,6 +625,38 @@ int main() {
self.assertEqual(result["components"]["repeater_scope_storage_fallback"], 18892)
self.assertEqual(result["components"]["repeater_advert_storage_fallback"], 17936)
def test_psram_table_elf_policy_uses_authoritative_mcu_over_missing_or_stale_sdk_macros(self):
flags = {"BOARD_HAS_PSRAM": 1, "MESH_CLIENT_REPEATER_ONLY": 1}
target = "board_repeater"
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "firmware.elf"
report = Path(temporary) / "firmware.memory.json"
for mcu, stale_macro in (("esp32s3", None), ("esp32s3", 0),
("esp32", None), ("esp32", 1)):
with self.subTest(mcu=mcu, stale_macro=stale_macro), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
defines = dict(flags)
if stale_macro is not None:
defines["CONFIG_IDF_TARGET_ESP32S3"] = stale_macro
expected = ram.requirements("ESP32_PLATFORM", {
**defines, "CONFIG_IDF_TARGET_ESP32S3": int(mcu == "esp32s3"),
"MESH_RAM_DEFAULT_RECENT_REPEATERS": 256 if mcu == "esp32" else 2048,
}, target)
capacity = expected["required_heap_bytes"]
# Classic ESP32 reclaims the second 64 KiB ROM-stack
# region. The S3 excludes it; both exclude the first 16 KiB.
length = capacity + 0x4000 - (0x10000 if mcu == "esp32" else 0)
esp_fixture(path, modern=True, internal_length=length)
status = ram.check_firmware(path, "ESP32_PLATFORM", mcu,
defines, target, report)
result = json.loads(report.read_text())
self.assertEqual(status, 0)
self.assertTrue(result["passed"])
self.assertEqual(result["available_internal_bytes"], capacity)
self.assertEqual(result["required_heap_bytes"], capacity)
self.assertEqual(result["components"], expected["components"])
for name in ("repeater_scope_storage_fallback", "repeater_advert_storage_fallback"):
self.assertEqual(name in result["components"], mcu == "esp32s3")
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")
+10 -1
View File
@@ -115,7 +115,6 @@ class FloodAdvertPsramTests(unittest.TestCase):
(cls.work / "esp_heap_caps.h").write_text(HEAP)
(cls.work / "sdkconfig.h").write_text(
"#define CONFIG_IDF_TARGET_ESP32S3 1\n"
"#define CONFIG_SPIRAM_BOOT_INIT 1\n"
"#define CONFIG_SPIRAM_USE_MALLOC 1\n")
cls.compiler = [os.environ.get("CXX", "g++")]
cls.binaries = {}
@@ -132,6 +131,16 @@ class FloodAdvertPsramTests(unittest.TestCase):
source = self.work / (name + ".cpp")
binary = self.work / name
code = HARNESS.replace("@BANKS@", "").replace("int main(", "int traceMain(")
code = code.replace(
"#define MAKE_LIMITER(name) mesh::StaticFloodAdvertLimiter<> name\n",
"#define MAKE_LIMITER(name) \\\n"
" const unsigned name##_calls = heap_test::heap().external_calls + heap_test::heap().internal_calls; \\\n"
" mesh::StaticFloodAdvertLimiter<> name; \\\n"
" CHECK(heap_test::heap().external_calls + heap_test::heap().internal_calls == name##_calls); \\\n"
" CHECK(name.begin()); \\\n"
" const unsigned name##_initialized = heap_test::heap().external_calls + heap_test::heap().internal_calls; \\\n"
" CHECK(name.begin()); \\\n"
" CHECK(heap_test::heap().external_calls + heap_test::heap().internal_calls == name##_initialized)\n")
# Release builds of the limiter are exercised while harness assertions
# stay active. The trace also checks exact full-key prefix collisions.
code = code.rstrip()[:-1] + " return 0;\n}\n"
+4
View File
@@ -66,6 +66,10 @@ public:
uint8_t flood_channel_data_rule_max_hops = FLOOD_CHANNEL_HOPS_ALL;
FloodChannelScopeEntry flood_channel_scopes[FLOOD_CHANNEL_SCOPE_SLOTS]{};
char flood_channel_direct_scopes[FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS][32]{};
auto floodChannelScopes() -> decltype(flood_channel_scopes)& { return flood_channel_scopes; }
auto floodChannelScopes() const -> const decltype(flood_channel_scopes)& { return flood_channel_scopes; }
auto floodChannelDirectScopes() -> decltype(flood_channel_direct_scopes)& { return flood_channel_direct_scopes; }
auto floodChannelDirectScopes() const -> const decltype(flood_channel_direct_scopes)& { return flood_channel_direct_scopes; }
uint8_t flood_packet_filter_blacklist_count = 0;
uint8_t flood_packet_filter_blacklist[18][3]{};
unsigned seeded = 0;
+46 -32
View File
@@ -13,7 +13,6 @@ from test_flood_advert_psram import HEAP
ROOT = Path(__file__).resolve().parents[1]
SDK = ("#define CONFIG_IDF_TARGET_ESP32S3 1\n"
"#define CONFIG_SPIRAM_BOOT_INIT 1\n"
"#define CONFIG_SPIRAM_USE_MALLOC 1\n")
DEFINES = ["ESP32_PLATFORM=1", "BOARD_HAS_PSRAM=1", "STATION_G3_ESP32=1",
"MESH_CLIENT_REPEATER_ONLY=1"]
@@ -112,19 +111,19 @@ static std::vector<uint8_t> snapshot(const MyMesh& mesh) {
const auto first = static_cast<const uint8_t*>(data);
bytes.insert(bytes.end(), first, first + size);
};
add(mesh.flood_channel_scopes, sizeof(mesh.flood_channel_scopes));
add(mesh.flood_channel_direct_scopes, sizeof(mesh.flood_channel_direct_scopes));
add(mesh.flood_channel_scope_requirements, sizeof(mesh.flood_channel_scope_requirements));
add(mesh.floodChannelScopes(), sizeof(mesh.floodChannelScopes()));
add(mesh.floodChannelDirectScopes(), sizeof(mesh.floodChannelDirectScopes()));
add(mesh.floodChannelScopeRequirements(), sizeof(mesh.floodChannelScopeRequirements()));
return bytes;
}
static void populate(MyMesh& mesh) {
for (unsigned i = 0; i < 32; ++i)
std::snprintf(mesh.flood_channel_direct_scopes[i], 32, "#Scope%u", i);
std::snprintf(mesh.floodChannelDirectScopes()[i], 32, "#Scope%u", i);
for (unsigned i = 0; i < 255; ++i) {
auto& row = mesh.flood_channel_scopes[i];
auto& row = mesh.floodChannelScopes()[i];
row.target_id = i % 32 + 1;
row.selector = FloodFilterPolicy::encodeChannelScopeTargetSelector(0, true);
auto& requirement = mesh.flood_channel_scope_requirements[i];
auto& requirement = mesh.floodChannelScopeRequirements()[i];
requirement.key_len = 32;
std::memset(requirement.secret, uint8_t(i + 1), sizeof(requirement.secret));
}
@@ -132,10 +131,13 @@ static void populate(MyMesh& mesh) {
static void exercise(const char* mode) {
MemoryFS disk;
{
const unsigned constructor_calls = heap_test::heap().external_calls + heap_test::heap().internal_calls;
MyMesh writer(disk);
static_assert(sizeof(writer.flood_channel_scopes) == 255 * 36, "all scope slots");
static_assert(sizeof(writer.flood_channel_direct_scopes) == 32 * 32, "all direct names");
static_assert(sizeof(writer.flood_channel_scope_requirements) == 255 * 34, "all requirements");
CHECK(heap_test::heap().external_calls + heap_test::heap().internal_calls == constructor_calls);
writer.begin();
static_assert(sizeof(writer.floodChannelScopes()) == 255 * 36, "all scope slots");
static_assert(sizeof(writer.floodChannelDirectScopes()) == 32 * 32, "all direct names");
static_assert(sizeof(writer.floodChannelScopeRequirements()) == 255 * 34, "all requirements");
#if MESH_REPEATER_PSRAM_TABLES
static_assert(sizeof(MyMesh) < 256, "no inline fallback arrays");
auto& heap = heap_test::heap();
@@ -153,24 +155,26 @@ static void exercise(const char* mode) {
(void)mode;
#endif
CHECK(snapshot(writer) == std::vector<uint8_t>(18874, 0));
const void* scopes = writer.flood_channel_scopes;
const void* direct = writer.flood_channel_direct_scopes;
const void* require = writer.flood_channel_scope_requirements;
const void* scopes = writer.floodChannelScopes();
const void* direct = writer.floodChannelDirectScopes();
const void* require = writer.floodChannelScopeRequirements();
populate(writer);
const auto initial = snapshot(writer);
writer.begin();
CHECK(snapshot(writer) == initial);
CHECK(writer.saveFloodChannelScopes());
CHECK(writer.saveFloodChannelScopeRequirements());
CHECK(disk.files["/scopes"].size() == 10210);
CHECK(disk.files["/require"].size() == 8675);
std::vector<uint8_t> expected = {'F','C','S','5',255};
const auto* start = reinterpret_cast<const uint8_t*>(writer.flood_channel_scopes);
const auto* start = reinterpret_cast<const uint8_t*>(writer.floodChannelScopes());
expected.insert(expected.end(), start, start + 9180);
expected.push_back(32);
start = reinterpret_cast<const uint8_t*>(writer.flood_channel_direct_scopes);
start = reinterpret_cast<const uint8_t*>(writer.floodChannelDirectScopes());
expected.insert(expected.end(), start, start + 1024);
CHECK(disk.files["/scopes"] == expected);
expected = {'F','C','R','1',255};
start = reinterpret_cast<const uint8_t*>(writer.flood_channel_scope_requirements);
start = reinterpret_cast<const uint8_t*>(writer.floodChannelScopeRequirements());
expected.insert(expected.end(), start, start + 8670);
CHECK(disk.files["/require"] == expected);
const auto saved = disk.files;
@@ -191,9 +195,9 @@ static void exercise(const char* mode) {
CHECK(snapshot(writer) == initial && disk.files == saved);
}
disk.fail_rename = disk.short_write = false;
std::memset(writer.flood_channel_scopes, 0xa5, sizeof(writer.flood_channel_scopes));
std::memset(writer.flood_channel_direct_scopes, 0xa5, sizeof(writer.flood_channel_direct_scopes));
std::memset(writer.flood_channel_scope_requirements, 0xa5, sizeof(writer.flood_channel_scope_requirements));
std::memset(writer.floodChannelScopes(), 0xa5, sizeof(writer.floodChannelScopes()));
std::memset(writer.floodChannelDirectScopes(), 0xa5, sizeof(writer.floodChannelDirectScopes()));
std::memset(writer.floodChannelScopeRequirements(), 0xa5, sizeof(writer.floodChannelScopeRequirements()));
CHECK(writer.loadFloodChannelScopes());
writer.loadFloodChannelScopeRequirements();
CHECK(snapshot(writer) == initial);
@@ -206,17 +210,17 @@ static void exercise(const char* mode) {
CHECK(writer.loadFloodChannelScopes());
writer.loadFloodChannelScopeRequirements();
CHECK(snapshot(writer) == initial);
CHECK(scopes == writer.flood_channel_scopes && direct == writer.flood_channel_direct_scopes
&& require == writer.flood_channel_scope_requirements);
CHECK(scopes == writer.floodChannelScopes() && direct == writer.floodChannelDirectScopes()
&& require == writer.floodChannelScopeRequirements());
// Compare the complete production file payloads between inline and PSRAM runs.
for (const char* path : {"/scopes", "/require"}) {
const auto& bytes = disk.files[path];
CHECK(std::fwrite(bytes.data(), 1, bytes.size(), stdout) == bytes.size());
}
writer.deleteFloodChannelScope(".255", reply);
CHECK(!std::strcmp(reply, "OK") && writer.flood_channel_scopes[254].target_id == 0);
CHECK(!std::strcmp(reply, "OK") && writer.floodChannelScopes()[254].target_id == 0);
writer.deleteFloodChannelScopeRequirement(".255", reply);
CHECK(!std::strcmp(reply, "OK") && writer.flood_channel_scope_requirements[254].key_len == 0);
CHECK(!std::strcmp(reply, "OK") && writer.floodChannelScopeRequirements()[254].key_len == 0);
writer.deleteFloodChannelScope("all", reply);
CHECK(!std::strcmp(reply, "OK"));
writer.deleteFloodChannelScopeRequirement("all", reply);
@@ -225,6 +229,8 @@ static void exercise(const char* mode) {
}
CHECK(heap_test::heap().blocks.empty());
MyMesh recreated(disk);
CHECK(heap_test::heap().blocks.empty());
recreated.begin();
CHECK(snapshot(recreated) == std::vector<uint8_t>(18874, 0));
}
int main(int argc, char** argv) {
@@ -260,10 +266,11 @@ def production_program():
start = header.rfind("#if MESH_REPEATER_PSRAM_TABLES", 0, location)
declarations = header[start:header.index("#if MESH_ENABLE_FLOOD_GROUP_MODERATION", location)]
constructor = source.index("MyMesh::MyMesh(")
start = source.index("#if MESH_REPEATER_PSRAM_TABLES", constructor)
binders = source[start:source.index("#endif", start) + len("#endif")]
start = source.index(" memset(flood_channel_scopes, 0, sizeof(flood_channel_scopes));", constructor)
start = source.index("#if !MESH_REPEATER_PSRAM_TABLES\n memset(flood_channel_scopes", constructor)
resets = source[start:source.index("#if MESH_ENABLE_FLOOD_GROUP_MODERATION", start)]
begin_start = source.index("void MyMesh::begin(FILESYSTEM *fs) {")
begin_start = source.index("{", begin_start) + 1
begin = source[begin_start:source.index("#if MESH_ENABLE_FLOOD_RULE_ENGINE", begin_start)]
methods = [
"bool MyMesh::loadFloodChannelScopes(", "bool MyMesh::saveFloodChannelScopes(",
"void MyMesh::loadFloodChannelScopeRequirements(",
@@ -277,12 +284,13 @@ def production_program():
"static bool isValidStoredFloodFilterScopeName(",
"static bool isExactFloodChannelScopeSelector(",
]
owner = "class MyMesh {\npublic:\nMemoryFS* _fs;\n"
owner = ("class MyMesh {\npublic:\nMemoryFS* _fs;\n"
"struct Limiter { bool begin() { return true; } } flood_advert_limiter;\n")
for name in ("FloodChannelScopeEntry", "FloodChannelScopeRequireEntry"):
owner += body(header, "struct " + name + " {") + ";\n"
owner += declarations + "\nint end_marker;\n"
owner += "explicit MyMesh(MemoryFS& fs) : _fs(&fs),\n" + binders
owner += "\nend_marker(0) {\n" + resets + "}\n"
owner += declarations
owner += "explicit MyMesh(MemoryFS& fs) : _fs(&fs) {\n" + resets + "}\n"
owner += "void begin() {\n" + begin + "}\n"
owner += ("bool loadFloodChannelScopes();\n"
"bool saveFloodChannelScopes(bool empty_table=false);\n"
"void loadFloodChannelScopeRequirements();\n"
@@ -353,11 +361,17 @@ class RepeaterScopePsramTests(unittest.TestCase):
cases = [([], 0, True, SDK), (["ESP32_PLATFORM=1", "STATION_G3_ESP32=1"], 0, True, SDK),
(["ESP32_PLATFORM=1", "BOARD_HAS_PSRAM=1"], 0, True, SDK),
(DEFINES[:-1], 0, True, SDK),
([define for define in DEFINES if not define.startswith("STATION_G3")], 1, True, SDK),
(DEFINES, 1, True, SDK), (DEFINES + ["MESH_REPEATER_PSRAM_TABLES=0"], 0, True, ""),
(["MESH_REPEATER_PSRAM_TABLES=1"], 1, False, SDK),
(DEFINES[:-1] + ["MESH_REPEATER_PSRAM_TABLES=1"], 1, False, SDK),
(DEFINES[:-1] + ["MESH_CLIENT_REPEATER_ONLY=0", "MESH_REPEATER_PSRAM_TABLES=1"],
1, False, SDK),
(DEFINES + ["MESH_REPEATER_PSRAM_TABLES=2"], 2, False, SDK),
(DEFINES, 1, False, SDK.replace("CONFIG_IDF_TARGET_ESP32S3 1", "CONFIG_IDF_TARGET_ESP32 1")),
(DEFINES, 1, False, SDK.replace("CONFIG_SPIRAM_BOOT_INIT 1", "CONFIG_SPIRAM_BOOT_INIT 0")),
(DEFINES, 0, True, SDK.replace("CONFIG_IDF_TARGET_ESP32S3 1", "CONFIG_IDF_TARGET_ESP32 1")),
(DEFINES + ["MESH_REPEATER_PSRAM_TABLES=1"], 1, False,
SDK.replace("CONFIG_IDF_TARGET_ESP32S3 1", "CONFIG_IDF_TARGET_ESP32 1")),
(DEFINES, 1, True, SDK + "#define CONFIG_SPIRAM_BOOT_INIT 1\n"),
(DEFINES, 1, False, SDK.replace("CONFIG_SPIRAM_USE_MALLOC 1", "CONFIG_SPIRAM_USE_MALLOC 0"))]
for index, (defines, expected, success, config) in enumerate(cases):
with self.subTest(index=index, defines=defines):
+10 -7
View File
@@ -280,7 +280,9 @@ class WsTransportPsramTest(unittest.TestCase):
cls.binaries = {}
variants = {
"g3_psram": (4, ["STATION_G3_ESP32", "BOARD_HAS_PSRAM"]),
"other_psram": (4, ["BOARD_HAS_PSRAM"]),
"generic_psram": (4, ["BOARD_HAS_PSRAM"]),
"classic_psram": (4, ["CONFIG_IDF_TARGET_ESP32", "BOARD_HAS_PSRAM"]),
"s3_psram": (4, ["CONFIG_IDF_TARGET_ESP32S3", "BOARD_HAS_PSRAM"]),
"g3_internal": (4, ["STATION_G3_ESP32"]),
"other_internal": (4, []),
"idf5": (5, ["STATION_G3_ESP32", "BOARD_HAS_PSRAM"]),
@@ -306,14 +308,15 @@ class WsTransportPsramTest(unittest.TestCase):
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_g3_psram_and_guarded_failure_lifecycles(self):
for scenario in ["external_success", "internal_boundary", "reserve_denied",
"largest_denied", "both_fail", "sdk_failure"]:
with self.subTest(scenario=scenario):
self.run_scenario("g3_psram", scenario)
def test_board_agnostic_psram_and_guarded_failure_lifecycles(self):
for variant in ["g3_psram", "generic_psram", "classic_psram", "s3_psram"]:
for scenario in ["external_success", "internal_boundary", "reserve_denied",
"largest_denied", "both_fail", "sdk_failure"]:
with self.subTest(variant=variant, scenario=scenario):
self.run_scenario(variant, scenario)
def test_other_target_allocation_defaults_and_failure_cleanup(self):
for variant in ["other_psram", "g3_internal", "other_internal"]:
for variant in ["g3_internal", "other_internal"]:
for scenario in ["generic_success", "generic_failure", "sdk_failure"]:
with self.subTest(variant=variant, scenario=scenario):
self.run_scenario(variant, scenario)