Qualify RAK repeater RAM and retire stale channel-busy waits

This commit is contained in:
mikecarper committed 2026-10-22 22:59:23 -07:00
1 parent e34e83e6b4
commit adb11e7b41
18 files changed
+535 -110

No files matched your search

+10
View File
@@ -64,6 +64,16 @@ jobs:
bash build.sh build-firmware Heltec_t114_repeater_lora_ota_no_external_sensors
--firmware-version v1.18.0.1-halo-keymind-cascade-dev
--radio-preset usa-cascade-fixed --profile cascade --require-ota
- name: Qualify both RAK4631 Repeater sensor and LoRa OTA profiles
run: >-
bash build.sh build-firmware RAK_4631_repeater
--build-profile auto --firmware-version v1.18.0.1-halo-keymind-cascade-dev
--radio-preset usa-cascade-fixed --profile cascade --require-ota
- name: Qualify both RAK3401 Room Server sensor and LoRa OTA profiles
run: >-
bash build.sh build-firmware RAK_3401_room_server
--build-profile auto --firmware-version v1.18.0.1-halo-keymind-cascade-dev
--radio-preset usa-cascade-fixed --profile cascade --require-ota
- name: Compile optional RAK15002 SD archive (no hardware qualification)
run: pio run -e RAK_4631_repeater_rak15002_sd_seeder_only
+19 -8
View File
@@ -37,7 +37,7 @@ MQTT reserve the same 49,152-byte WiFi allowance even without `WIFI_SSID` or
`WIFI_OTA_SEEDER`: saved runtime credentials can activate WiFi. Multiple WiFi
features count that allowance once; PSRAM-backed TLS does not remove it.
ESP32 images with
RS-232 reserve another 6,656 bytes even when UART defaults off: 2,560 bytes for
RS-232 reserve another 7,168 bytes even when UART defaults off: 3,072 bytes for
the source-bounded bridge object and allocation metadata, plus an unchanged
4 KiB for the UART driver and buffers. This
keeps runtime UART enablement in the budget beside MQTT and ESP-NOW.
@@ -68,10 +68,11 @@ The old 75,396-byte linked capacity therefore failed correctly; the check is
not bypassed or relaxed to accept it. Full Companion's independent 72 KiB
floor remains in place.
ESP32 SSD1306 uses a source-bounded 2 KiB
allowance for its fixed 1,024-byte framebuffer, driver and metadata; other
OLED and ordinary non-ESP32 allowances remain unchanged. nRF52 Full with that
color framebuffer must have at least 72 KiB available before startup allocations.
ESP32 and nRF52 SSD1306 use a source-bounded 2 KiB allowance for the same
fixed 1,024-byte framebuffer, embedded driver and allocation metadata. The
OLED auto-detection backend reuses that one buffer across SSD1306/SH1106
selection and retries. Other display allowances remain unchanged. nRF52 Full
with that color framebuffer must have at least 72 KiB available before startup allocations.
Headless and OLED devices use their own smaller totals. The JSON lists each
component and checks the largest available region against the largest planned
single allocation.
@@ -162,7 +163,7 @@ physical boot/soak or maximum TLS/WSS/outbox-load qualification. It also does
not qualify an independently built compact portable-slot recipe.
The experimental RAK4631 combined Ethernet/UART2/adaptive-storage config adds
an explicit 2,560-byte allowance for the UART bridge and its TX semaphore.
an explicit 3,072-byte allowance for the UART bridge and its TX semaphore.
Its Ethernet worker reserves 1,024 stack words (4,096 bytes) plus 256 bytes for
task metadata and allocator overhead. Its bounded TCP transmit queue allocates
512 bytes only while Ethernet is enabled; another 64 bytes cover allocator
@@ -197,8 +198,9 @@ for allocation overhead still leaves 836 bytes within the allowance.
A compile-time assertion
covers framebuffer, complete driver object and allocator overhead within that
bound. This corrects a padded allocation estimate; it does not free actual RAM.
Other images retain their existing display budgets. These are local
test configs, not a qualification of a published or physically tested image.
The same source assertion and bound now cover ordinary nRF52 SSD1306 images
as well; other display drivers keep their existing budgets. These are linked
capacity checks, not qualification of a published or physically tested image.
## Shared RAM reductions
@@ -211,6 +213,7 @@ stored settings, OTA block sizes and source-image limits.
| Repeater ACL session state | Pure repeater builds on all platforms | 36 bytes per client on 32-bit targets; 1,152 bytes for 32 clients |
| Compact recent-repeater records | Repeaters with recent history enabled | 12 to 9 bytes per record; 1,536 bytes for 512 records |
| Compact flood-rule flags and aligned counters | Generalized repeater and room rule engines | 200 to 192 bytes per rule; 504 bytes for 63 repeater rules or 248 bytes for 31 room rules |
| One tagged flood-rule target name | Generalized repeater engine on all platforms | 332 to 300 bytes per extended rule; 2,016 constructor-time heap bytes for all 63 slots |
| Streaming Merkle proof generation | All OTA roles and platforms | Removes full-leaf-table proof scratch; compressed output needs only one logical block |
| Source buffers allocated on demand | All OTA roles and platforms | Leaves allocate only the selected image's digest count; unused leaves/output return to the heap |
@@ -225,6 +228,14 @@ loop; network command handlers marshal their work to that loop. These byte
operations do not provide synchronization for hypothetical concurrent users.
Flood flags remain internal; FPF files serialize each field explicitly and
never expose compiler bitfield layout. Multibyte counters remain aligned.
Repeater rules store either the direct scope name or target region name in
one 32-byte array because the CLI and stored-policy validator already reject
both actions together. A spare flag bit identifies the name. FPF6 retains its
single scope field. FPF7/8 retain both fixed-width name fields; the inactive field
is emitted as 32 zero bytes. Load reads and validates both fields before
selecting the shared name, retaining maximum name lengths and refusing a
malformed two-target policy. Room-engine rows and on-disk formats are unchanged.
Rule deduplication compares configured fields explicitly, so neither live
rate-window counters nor struct padding can make different rules share a slot.
+82 -69
View File
@@ -6487,6 +6487,8 @@ bool MyMesh::loadFloodPacketFilters() {
for (int i = 0; success && i < count; i++) {
auto& entry = i < flood_packet_filter_slots ? loaded[i] : overflow_entry;
if (i >= flood_packet_filter_slots) memset(&entry, 0, sizeof(entry));
char stored_scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN] = {};
char stored_region_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN] = {};
uint8_t active = 0;
uint8_t suspend_on_temp_radio = 0;
uint8_t match_blacklisted_path = 0;
@@ -6503,11 +6505,11 @@ bool MyMesh::loadFloodPacketFilters() {
success = success && file.read(&suspend_on_temp_radio,
sizeof(suspend_on_temp_radio))
== sizeof(suspend_on_temp_radio);
success = success && file.read((uint8_t*)entry.scope_name,
sizeof(entry.scope_name))
== sizeof(entry.scope_name);
success = success && file.read((uint8_t*)stored_scope_name,
sizeof(stored_scope_name))
== sizeof(stored_scope_name);
success = success
&& memchr(entry.scope_name, 0, sizeof(entry.scope_name)) != NULL;
&& memchr(stored_scope_name, 0, sizeof(stored_scope_name)) != NULL;
success = success && file.read(&match_blacklisted_path,
sizeof(match_blacklisted_path))
== sizeof(match_blacklisted_path);
@@ -6559,9 +6561,9 @@ bool MyMesh::loadFloodPacketFilters() {
sizeof(entry.rate_per_minute))
== sizeof(entry.rate_per_minute);
success = success
&& file.read((uint8_t*)entry.target_region_name,
sizeof(entry.target_region_name))
== sizeof(entry.target_region_name);
&& file.read((uint8_t*)stored_region_name,
sizeof(stored_region_name))
== sizeof(stored_region_name);
success = success
&& file.read(&entry.priority, sizeof(entry.priority))
== sizeof(entry.priority);
@@ -6575,7 +6577,7 @@ bool MyMesh::loadFloodPacketFilters() {
entry.incoming_scope_kind = scope_requires_region_match
? FloodFilterPolicy::RULE_IN_ALLOWED
: FloodFilterPolicy::RULE_IN_ANY;
drop_on_match = entry.scope_name[0] == 0 ? 1 : 0;
drop_on_match = stored_scope_name[0] == 0 ? 1 : 0;
}
if (success && version_7) {
success = FloodFilterPolicy::decodeStoredRuleChannel(
@@ -6604,8 +6606,8 @@ bool MyMesh::loadFloodPacketFilters() {
success = false;
}
success = success && FloodFilterPolicy::transportActionsSupported(
entry.transport_modes, entry.scope_name[0] != 0
|| entry.target_region_name[0] != 0,
entry.transport_modes, stored_scope_name[0] != 0
|| stored_region_name[0] != 0,
entry.retry_on_match, entry.scope_uses_slow_timing);
if (!success) break;
if (!entry.active) {
@@ -6613,12 +6615,12 @@ bool MyMesh::loadFloodPacketFilters() {
continue;
}
bool direct_target = entry.scope_name[0] != 0;
bool direct_target = stored_scope_name[0] != 0;
bool target_name_terminated = memchr(
entry.target_region_name, 0,
sizeof(entry.target_region_name)) != NULL;
stored_region_name, 0,
sizeof(stored_region_name)) != NULL;
bool region_target = target_name_terminated
&& entry.target_region_name[0] != 0;
&& stored_region_name[0] != 0;
bool input_name_terminated = memchr(
entry.incoming_scope_name, 0,
sizeof(entry.incoming_scope_name)) != NULL;
@@ -6674,6 +6676,12 @@ bool MyMesh::loadFloodPacketFilters() {
|| entry.payload_type == PAYLOAD_TYPE_GRP_DATA;
}
// Keep malformed two-target records invalid; never silently select one.
if (target_name_terminated && !(direct_target && region_target)) {
entry.target_is_region = region_target;
memcpy(entry.target_name, region_target ? stored_region_name : stored_scope_name,
sizeof(entry.target_name));
}
bool path_valid = FloodRuleExtensions::validRuleFields(entry);
bool action_valid = entry.drop_on_match || direct_target
|| region_target || entry.rate_limit_enabled
@@ -6683,11 +6691,11 @@ bool MyMesh::loadFloodPacketFilters() {
&& entry.min_hops <= entry.max_hops
&& entry.max_hops <= FLOOD_PACKET_FILTER_MAX_HOPS
&& (!direct_target
|| isValidStoredFloodFilterScopeName(entry.scope_name))
|| isValidStoredFloodFilterScopeName(stored_scope_name))
&& target_name_terminated
&& (!region_target
|| isValidStoredFloodRuleRegionName(
entry.target_region_name))
stored_region_name))
&& !(direct_target && region_target)
&& !(entry.drop_on_match && (direct_target || region_target))
&& !(entry.drop_on_match && entry.rate_limit_enabled)
@@ -6701,6 +6709,11 @@ bool MyMesh::loadFloodPacketFilters() {
&& action_valid)) {
success = false;
}
if (success) {
entry.target_is_region = region_target;
memcpy(entry.target_name, region_target ? stored_region_name : stored_scope_name,
sizeof(entry.target_name));
}
if (success && i >= flood_packet_filter_slots) active_overflow = true;
}
@@ -6928,7 +6941,7 @@ bool MyMesh::isFloodChannelDataRule(
&& valid_hops
&& entry.max_hops == FLOOD_PACKET_FILTER_MAX_HOPS
&& !entry.suspend_on_temp_radio
&& entry.scope_name[0] == 0
&& entry.scopeName()[0] == 0
&& !entry.match_blacklisted_path
&& !entry.scope_uses_slow_timing
&& entry.incoming_scope_kind == FloodFilterPolicy::RULE_IN_ANY
@@ -6937,7 +6950,7 @@ bool MyMesh::isFloodChannelDataRule(
&& entry.channel_name[0] == 0
&& entry.path_hash_size == 0
&& entry.path_hops == 0
&& entry.target_region_name[0] == 0
&& entry.regionName()[0] == 0
&& entry.drop_on_match
&& !entry.rate_limit_enabled
&& entry.priority == 0
@@ -7218,7 +7231,7 @@ bool MyMesh::migrateLegacyFloodChannelBlocks() {
&& entry.min_hops == min_hops
&& entry.max_hops == FLOOD_PACKET_FILTER_MAX_HOPS
&& !entry.suspend_on_temp_radio
&& entry.scope_name[0] == 0
&& entry.scopeName()[0] == 0
&& !entry.match_blacklisted_path
&& !entry.scope_uses_slow_timing
&& entry.incoming_scope_kind == FloodFilterPolicy::RULE_IN_ANY
@@ -7226,7 +7239,7 @@ bool MyMesh::migrateLegacyFloodChannelBlocks() {
&& entry.channel_key_len == row.key_len
&& memcmp(entry.channel_secret, row.secret, row.key_len) == 0
&& entry.path_hash_size == 0 && entry.path_hops == 0
&& entry.target_region_name[0] == 0
&& entry.regionName()[0] == 0
&& entry.drop_on_match && !entry.rate_limit_enabled
&& !entry.stop_on_match;
if (duplicate) break;
@@ -7323,7 +7336,7 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase,
&& writeExact(&entry.min_hops, sizeof(entry.min_hops))
&& writeExact(&entry.max_hops, sizeof(entry.max_hops))
&& writeExact(&suspend_on_temp_radio, sizeof(suspend_on_temp_radio))
&& writeExact(entry.scope_name, sizeof(entry.scope_name))
&& writeExact(entry.scopeName(), sizeof(entry.scopeName()))
&& writeExact(&match_blacklisted_path,
sizeof(match_blacklisted_path))
&& writeExact(&scope_requires_region_match,
@@ -7343,8 +7356,8 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase,
&& writeExact(&drop_on_match, sizeof(drop_on_match))
&& writeExact(&rate_limit_enabled, sizeof(rate_limit_enabled))
&& writeExact(&entry.rate_per_minute, sizeof(entry.rate_per_minute))
&& writeExact(entry.target_region_name,
sizeof(entry.target_region_name))
&& writeExact(entry.regionName(),
sizeof(entry.regionName()))
&& writeExact(&entry.priority, sizeof(entry.priority))
&& writeExact(&stop_on_match, sizeof(stop_on_match));
if (success && version_8) {
@@ -7460,11 +7473,11 @@ bool MyMesh::loadFloodPacketFilters() {
success = success && file.read(&suspend_on_temp_radio,
sizeof(suspend_on_temp_radio))
== sizeof(suspend_on_temp_radio);
success = success && file.read((uint8_t*)loaded[i].scope_name,
sizeof(loaded[i].scope_name))
== sizeof(loaded[i].scope_name);
success = success && file.read((uint8_t*)loaded[i].scopeName(),
sizeof(loaded[i].scopeName()))
== sizeof(loaded[i].scopeName());
success = success
&& memchr(loaded[i].scope_name, 0, sizeof(loaded[i].scope_name)) != NULL;
&& memchr(loaded[i].scopeName(), 0, sizeof(loaded[i].scopeName())) != NULL;
success = success && file.read(&match_blacklisted_path,
sizeof(match_blacklisted_path))
== sizeof(match_blacklisted_path);
@@ -7483,7 +7496,7 @@ bool MyMesh::loadFloodPacketFilters() {
|| match_blacklisted_path > 1 || scope_requires_region_match > 1
|| scope_uses_slow_timing > 1
|| (!loaded[i].active
&& (loaded[i].suspend_on_temp_radio || loaded[i].scope_name[0] != 0
&& (loaded[i].suspend_on_temp_radio || loaded[i].scopeName()[0] != 0
|| loaded[i].match_blacklisted_path
|| loaded[i].scope_requires_region_match
|| loaded[i].scope_uses_slow_timing)))) {
@@ -7494,17 +7507,17 @@ bool MyMesh::loadFloodPacketFilters() {
|| loaded[i].payload_type == FLOOD_PACKET_FILTER_ANY_TYPE)
&& loaded[i].min_hops <= loaded[i].max_hops
&& loaded[i].max_hops <= FLOOD_PACKET_FILTER_MAX_HOPS
&& (loaded[i].scope_name[0] == 0
|| isValidStoredFloodFilterScopeName(loaded[i].scope_name))
&& (loaded[i].scopeName()[0] == 0
|| isValidStoredFloodFilterScopeName(loaded[i].scopeName()))
&& (!loaded[i].scope_requires_region_match
|| loaded[i].scope_name[0] != 0)
|| loaded[i].scopeName()[0] != 0)
&& (!loaded[i].scope_uses_slow_timing
|| loaded[i].scope_name[0] != 0))) {
|| loaded[i].scopeName()[0] != 0))) {
success = false;
}
if (success && loaded[i].scope_name[0] != 0) {
StrHelper::strzcpy(loaded[i].scope_name, loaded[i].scope_name,
sizeof(loaded[i].scope_name));
if (success && loaded[i].scopeName()[0] != 0) {
StrHelper::strzcpy(loaded[i].scopeName(), loaded[i].scopeName(),
sizeof(loaded[i].scopeName()));
}
}
file.close();
@@ -7544,8 +7557,8 @@ bool MyMesh::saveFloodPacketFilters(bool empty_scope_phase,
success = success && file.write(&entry.min_hops, sizeof(entry.min_hops)) == sizeof(entry.min_hops);
success = success && file.write(&entry.max_hops, sizeof(entry.max_hops)) == sizeof(entry.max_hops);
success = success && file.write(&suspend_on_temp_radio, sizeof(suspend_on_temp_radio)) == sizeof(suspend_on_temp_radio);
success = success && file.write((const uint8_t*)entry.scope_name,
sizeof(entry.scope_name)) == sizeof(entry.scope_name);
success = success && file.write((const uint8_t*)entry.scopeName(),
sizeof(entry.scopeName())) == sizeof(entry.scopeName());
success = success && file.write(&match_blacklisted_path,
sizeof(match_blacklisted_path))
== sizeof(match_blacklisted_path);
@@ -7704,14 +7717,14 @@ uint64_t MyMesh::applyFloodPacketFilterStop(uint64_t match_mask) {
specificities[i] = FloodFilterPolicy::channelMatcherSpecificity(
entry.channel_key_len);
bool region_usable = true;
if (entry.target_region_name[0] != 0) {
if (entry.regionName()[0] != 0) {
TransportKey scope;
const char* canonical_name = NULL;
region_usable = resolveFloodPacketFilterTargetRegion(
entry.target_region_name, scope, canonical_name);
entry.regionName(), scope, canonical_name);
}
stop_flags[i] = FloodFilterPolicy::stopActionApplies(
entry.stop_on_match, entry.target_region_name[0] != 0,
entry.stop_on_match, entry.regionName()[0] != 0,
region_usable) ? 1 : 0;
}
return FloodFilterPolicy::truncateRulesAtStop(
@@ -7792,18 +7805,18 @@ bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet,
if (i < 0) break;
visited |= (uint64_t)1U << i;
const auto& entry = flood_packet_filters[i];
if (entry.scope_name[0] == 0
&& entry.target_region_name[0] == 0) {
if (entry.scopeName()[0] == 0
&& entry.regionName()[0] == 0) {
continue;
}
TransportKey scope;
const char* target_name = entry.scope_name;
if (entry.scope_name[0] != 0) {
deriveFloodFilterScopeKey(entry.scope_name, scope);
const char* target_name = entry.scopeName();
if (entry.scopeName()[0] != 0) {
deriveFloodFilterScopeKey(entry.scopeName(), scope);
} else {
if (!resolveFloodPacketFilterTargetRegion(
entry.target_region_name, scope, target_name)) {
entry.regionName(), scope, target_name)) {
continue;
}
}
@@ -7927,10 +7940,10 @@ bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet,
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;
|| entry.scopeName()[0] == 0) continue;
TransportKey scope;
deriveFloodFilterScopeKey(entry.scope_name, scope);
deriveFloodFilterScopeKey(entry.scopeName(), scope);
bool scope_changed = FloodFilterPolicy::setTransportScope(
packet, scope.calcTransportCode(packet));
scope_set = true;
@@ -7939,7 +7952,7 @@ bool MyMesh::applyFloodPacketFilterScope(mesh::Packet* packet,
if (scope_changed && log_change) {
MESH_DEBUG_PRINTLN("flood.filter set scope slot=%d type=%d hops=%d scope=%s tx=%s",
i + 1, packet->getPayloadType(),
packet->getPathHashCount(), entry.scope_name,
packet->getPathHashCount(), entry.scopeName(),
entry.scope_uses_slow_timing ? "slow" : "fast");
}
return scope_changed;
@@ -7955,7 +7968,7 @@ bool MyMesh::shouldBlockFloodPacketForward(const mesh::Packet* packet,
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) {
&& entry.scopeName()[0] == 0) {
MESH_DEBUG_PRINTLN("allowPacketForward: flood.filter matched slot=%d type=%d hops=%d range=%d-%d",
i + 1, type, hops, entry.min_hops,
entry.max_hops);
@@ -8043,13 +8056,13 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_
action[0] = 0;
if (entry.drop_on_match) {
strcpy(action, "drop");
} else if (entry.scope_name[0] != 0) {
snprintf(action, sizeof(action), "scope=%s", entry.scope_name);
} else if (entry.target_region_name[0] != 0) {
} else if (entry.scopeName()[0] != 0) {
snprintf(action, sizeof(action), "scope=%s", entry.scopeName());
} else if (entry.regionName()[0] != 0) {
snprintf(action, sizeof(action), "region=%s%s",
entry.target_region_name,
entry.regionName(),
floodRuleRegionNamePresent(
region_map, entry.target_region_name) ? "" : "?");
region_map, entry.regionName()) ? "" : "?");
}
rate[0] = 0;
if (entry.rate_limit_enabled) {
@@ -8133,11 +8146,11 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply, bool compac
formatFloodFilterHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops);
if (entry.drop_on_match) {
strcpy(target, "!drop");
} else if (entry.scope_name[0] != 0) {
snprintf(target, sizeof(target), ">%s", entry.scope_name);
} else if (entry.target_region_name[0] != 0) {
} else if (entry.scopeName()[0] != 0) {
snprintf(target, sizeof(target), ">%s", entry.scopeName());
} else if (entry.regionName()[0] != 0) {
snprintf(target, sizeof(target), ">r:%s",
entry.target_region_name);
entry.regionName());
} else {
target[0] = 0;
}
@@ -8681,8 +8694,10 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
candidate.path_hash_size = path_hash_size;
candidate.path_hops = path_hops;
memcpy(candidate.path, path, sizeof(candidate.path));
StrHelper::strzcpy(candidate.target_region_name, target_region_name,
sizeof(candidate.target_region_name));
candidate.target_is_region = target_region_name[0] != 0;
StrHelper::strzcpy(candidate.target_name,
candidate.target_is_region ? target_region_name : scope_name,
sizeof(candidate.target_name));
candidate.drop_on_match = drop_on_match;
candidate.rate_limit_enabled = rate_limit_enabled;
candidate.rate_per_minute = rate_per_minute;
@@ -8691,8 +8706,6 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
candidate.retry_on_match = retry_on_match;
candidate.transport_modes = transport_modes;
candidate.extra = extra;
StrHelper::strzcpy(candidate.scope_name, scope_name,
sizeof(candidate.scope_name));
if (!FloodRuleExtensions::validRuleFields(candidate)) {
strcpy(reply, "Err - content needs a keyed group-text channel; scope/retry needs flood route");
return;
@@ -8758,8 +8771,8 @@ void MyMesh::formatFloodPacketFilterDetail(int index, char* reply, size_t reply_
char hops[12];
formatFloodFilterHopSpec(hops, sizeof(hops), entry.min_hops, entry.max_hops);
const char* suspension = entry.suspend_on_temp_radio ? " suspend=tempradio" : "";
const char* scope_prefix = entry.scope_name[0] ? " scope=" : "";
const char* scope_name = entry.scope_name[0] ? entry.scope_name : "";
const char* scope_prefix = entry.scopeName()[0] ? " scope=" : "";
const char* scope_name = entry.scopeName()[0] ? entry.scopeName() : "";
const char* path_match = entry.match_blacklisted_path ? " path=blacklist" : "";
const char* region_requirement =
entry.scope_requires_region_match ? " require=region" : "";
@@ -8805,8 +8818,8 @@ void MyMesh::formatFloodPacketFilters(const char* args, char* reply, bool compac
snprintf(item, sizeof(item), " %d=%s@%s%s%s%s%s%s%s",
i + 1, floodFilterPayloadTypeName(entry.payload_type), hops,
entry.match_blacklisted_path ? "?blacklist" : "",
entry.scope_name[0] ? ">" : "",
entry.scope_name,
entry.scopeName()[0] ? ">" : "",
entry.scopeName(),
entry.scope_requires_region_match ? "!region" : "",
entry.scope_uses_slow_timing ? "~slow" : "",
entry.suspend_on_temp_radio ? "~tempradio" : "");
@@ -8967,7 +8980,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
&& entry.suspend_on_temp_radio == suspend_on_temp_radio
&& entry.match_blacklisted_path == match_blacklisted_path
&& entry.scope_requires_region_match == scope_requires_region_match
&& strcmp(entry.scope_name, scope_name) == 0) {
&& strcmp(entry.scopeName(), scope_name) == 0) {
slot = i;
break;
}
@@ -8996,7 +9009,7 @@ void MyMesh::setFloodPacketFilter(const char* args, char* reply,
entry.match_blacklisted_path = match_blacklisted_path;
entry.scope_requires_region_match = scope_requires_region_match;
entry.scope_uses_slow_timing = scope_uses_slow_timing;
StrHelper::strzcpy(entry.scope_name, scope_name, sizeof(entry.scope_name));
StrHelper::strzcpy(entry.scopeName(), scope_name, sizeof(entry.scopeName()));
if (!saveFloodPacketFilters()) {
entry = previous;
strcpy(reply, "Err - unable to save flood filter");
+15 -4
View File
@@ -421,7 +421,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
};
struct FloodPacketFilterEntry {
#if MESH_ENABLE_FLOOD_RULE_ENGINE
// Keep multibyte counters naturally aligned. These seven independent
// Keep multibyte counters naturally aligned. These eight independent
// flags share a byte; their bit order is never persisted (FPF7 serializes
// each field explicitly). active/retry remain addressable for the stored
// rule decoders. Rule mutations run on the mesh loop.
@@ -435,12 +435,22 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
bool rate_limit_enabled : 1;
bool stop_on_match : 1;
bool rate_window_active : 1;
bool target_is_region : 1;
bool active;
bool retry_on_match;
uint8_t payload_type;
uint8_t min_hops;
uint8_t max_hops;
char scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
// scope= and region= cannot coexist; retain their full name capacity.
char target_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
const char (&scopeName() const)[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN] {
static const char empty[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN] = {};
return target_is_region ? empty : target_name;
}
const char (&regionName() const)[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN] {
static const char empty[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN] = {};
return target_is_region ? target_name : empty;
}
uint8_t incoming_scope_kind;
char incoming_scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
uint8_t channel_key_len;
@@ -450,7 +460,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
uint8_t path_hash_size;
uint8_t path_hops;
uint8_t path[FLOOD_PACKET_FILTER_PATH_PREFIX_BYTES_MAX];
char target_region_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
uint8_t priority;
uint8_t transport_modes;
FloodRuleExtensions::Fields extra;
@@ -461,6 +470,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
uint8_t max_hops;
bool suspend_on_temp_radio;
char scope_name[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
char (&scopeName())[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN] { return scope_name; }
const char (&scopeName() const)[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN] { return scope_name; }
bool match_blacklisted_path;
bool scope_requires_region_match;
bool scope_uses_slow_timing;
@@ -507,7 +518,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
};
mutable FloodRetryBridgeState flood_retry_bridge_states[TOTAL_FLOOD_RETRY_SLOTS];
FloodRetryBridgeReachability flood_retry_bridge_reachability[FLOOD_RETRY_BRIDGE_BUCKETS + 1];
static_assert(sizeof(FloodPacketFilterEntry) <= (MESH_ENABLE_FLOOD_RULE_ENGINE ? 336 : 40),
static_assert(sizeof(FloodPacketFilterEntry) <= (MESH_ENABLE_FLOOD_RULE_ENGINE ? 304 : 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
+2 -2
View File
@@ -197,7 +197,7 @@ def requirements(platform, defines, target):
if combined_rak_ethernet and (platform != "NRF52_PLATFORM"
or not {"RAK_4631", "ETHERNET_ENABLED", "OTA_RAK_AUTO_STORE"}.issubset(defines)):
raise ValueError("combined RAK Ethernet budget requires RAK4631 adaptive nRF52 storage")
if (combined_rak_ethernet or platform == "ESP32_PLATFORM") and display == "SSD1306Display":
if platform in {"ESP32_PLATFORM", "NRF52_PLATFORM"} and display == "SSD1306Display":
# The fixed 128x64 driver allocates one 1024-byte framebuffer and
# reuses it after power cycling. Its linked source assertion binds
# framebuffer + complete driver object + allocator overhead to 2 KiB.
@@ -308,7 +308,7 @@ def requirements(platform, defines, target):
if repeater:
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 * (336 if engine else 40) + 16
parts["flood_filter_table"] = slots * (304 if engine else 40) + 16
if "room_server" in target.lower() and integer(
defines, "MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE", 0):
# FloodRuleEngine keeps all 31 persisted rules in one startup heap
+32 -4
View File
@@ -96,6 +96,8 @@ void Dispatcher::begin() {
n_recv_flood = n_recv_direct = 0;
last_meshcore_recv_millis = 0;
_err_flags = 0;
cad_busy_start = 0;
profile_cad_busy[0] = profile_cad_busy[1] = 0;
outbound_radio_retry_at = 0;
outbound_radio_prepare_deadline = 0;
outbound_radio_retry_pending = false;
@@ -329,6 +331,7 @@ bool Dispatcher::queueDirectOtaRadioFrame(const uint8_t* message, uint16_t lengt
const uint16_t encoded = ota::ota_direct_wire_encode(
foreign_radio_bytes, sizeof(foreign_radio_bytes), message, length);
if (!encoded) return false;
retireUnusedCADWait(profile);
foreign_radio_length = encoded;
foreign_radio_profile = profile;
foreign_radio_protocol = RadioProfileProtocol::MeshCore;
@@ -353,6 +356,7 @@ bool Dispatcher::queueForeignRadioFrame(const uint8_t* bytes, size_t length,
|| !profiles || profile != 1 || !profiles->enabled() || !profiles->canTransmit(profile)
|| profiles->params(profile).protocol != protocol
|| generation != profiles->generation[profile]) return false;
retireUnusedCADWait(profile);
memcpy(foreign_radio_bytes, bytes, length);
foreign_radio_length = (uint16_t)length;
foreign_radio_profile = profile;
@@ -1382,6 +1386,30 @@ void Dispatcher::releasePacket(Packet* packet) {
_mgr->free(packet);
}
void Dispatcher::retireUnusedCADWait(uint8_t profile) {
if (profile_cad_busy[profile] && !(outbound && outbound->radio_profile == profile)
#if MESH_ENABLE_FOREIGN_RADIO
&& !(hasForeignRadioFrame() && foreign_radio_profile == profile)
#endif
) {
// Cancellation/echo suppression can retire the last packet for a profile
// without observing a free channel. Its old age must not force a later,
// unrelated packet to transmit immediately with a false timeout. Check
// only on enqueue with an aged wait, never during the idle/sleep loop.
bool waiting = false;
for (int i = 0, count = _mgr->getOutboundTotal(); i < count; ++i) {
const auto* queued = _mgr->getOutboundByIdx(i);
if (queued && queued->radio_profile == profile) { waiting = true; break; }
}
if (!waiting) profile_cad_busy[profile] = 0;
}
}
bool Dispatcher::queueTransmitCandidate(Packet* packet, uint8_t priority, uint32_t scheduled_for) {
retireUnusedCADWait(packet->radio_profile);
return _mgr->queueOutbound(packet, priority, scheduled_for);
}
bool Dispatcher::queueOutboundPacket(Packet* packet, uint8_t priority, uint32_t delay_millis) {
if (packet->violatesRoutePolicy()) {
return false;
@@ -1399,13 +1427,13 @@ bool Dispatcher::queueOutboundPacket(Packet* packet, uint8_t priority, uint32_t
if (profiles == nullptr) {
if (packet->tx_radio != RADIO_TX_AUTO
&& !(explicitRadioTxMask(packet->tx_radio, false) & 1)) return false;
return _mgr->queueOutbound(packet, priority, futureMillis(delay_millis));
return queueTransmitCandidate(packet, priority, futureMillis(delay_millis));
}
if (packet->radio_bound) {
if (!isPacketRadioCurrent(packet)) return false;
if (profiles && packet->radio_profile != packet->radio_origin
&& !allowRadioProfileCross(packet)) return false;
return _mgr->queueOutbound(packet, priority, futureMillis(delay_millis));
return queueTransmitCandidate(packet, priority, futureMillis(delay_millis));
}
if (!packet->radio_local && packet->radio_generation
&& packet->radio_generation != profiles->generation[packet->radio_profile]) return false;
@@ -1436,7 +1464,7 @@ bool Dispatcher::queueOutboundPacket(Packet* packet, uint8_t priority, uint32_t
}
packet->radio_generation = profiles->generation[packet->radio_profile];
packet->radio_bound = true;
if (!_mgr->queueOutbound(packet, priority, futureMillis(delay_millis))) return false;
if (!queueTransmitCandidate(packet, priority, futureMillis(delay_millis))) return false;
if (packet->radio_profile != origin) onRadioProfileCopyQueued(packet, nullptr, priority);
const uint8_t other = packet->radio_profile ^ 1;
if (mask & (1U << other)) {
@@ -1445,7 +1473,7 @@ bool Dispatcher::queueOutboundPacket(Packet* packet, uint8_t priority, uint32_t
*copy = *packet;
copy->radio_profile = other;
copy->radio_generation = profiles->generation[other];
if (_mgr->queueOutbound(copy, priority, futureMillis(delay_millis))) {
if (queueTransmitCandidate(copy, priority, futureMillis(delay_millis))) {
onRadioProfileCopyQueued(copy, packet, priority);
onTracePacketQueuedForSend(copy);
} else {
+2
View File
@@ -723,6 +723,8 @@ public:
unsigned long futureMillis(int millis_from_now) const;
private:
void retireUnusedCADWait(uint8_t profile);
bool queueTransmitCandidate(Packet* packet, uint8_t priority, uint32_t scheduled_for);
// Keep the frequently accessed core fields at compact ARM load offsets.
AGCResetScheduler agc_reset;
uint32_t receive_context_generation = 0;
+3 -3
View File
@@ -68,7 +68,7 @@ bool sameRuleConfiguration(const Entry& left, const Entry& right) {
&& left.min_hops == right.min_hops
&& left.max_hops == right.max_hops
&& left.suspend_on_temp_radio == right.suspend_on_temp_radio
&& memcmp(left.scope_name, right.scope_name, sizeof(left.scope_name)) == 0
&& memcmp(FloodRuleTargets::scopeName(left), FloodRuleTargets::scopeName(right), sizeof(FloodRuleTargets::scopeName(left))) == 0
&& left.match_blacklisted_path == right.match_blacklisted_path
&& left.scope_uses_slow_timing == right.scope_uses_slow_timing
&& left.incoming_scope_kind == right.incoming_scope_kind
@@ -82,8 +82,8 @@ bool sameRuleConfiguration(const Entry& left, const Entry& right) {
&& left.path_hash_size == right.path_hash_size
&& left.path_hops == right.path_hops
&& memcmp(left.path, right.path, sizeof(left.path)) == 0
&& memcmp(left.target_region_name, right.target_region_name,
sizeof(left.target_region_name)) == 0
&& memcmp(FloodRuleTargets::regionName(left), FloodRuleTargets::regionName(right),
sizeof(FloodRuleTargets::regionName(left))) == 0
&& left.drop_on_match == right.drop_on_match
&& left.rate_limit_enabled == right.rate_limit_enabled
&& left.rate_per_minute == right.rate_per_minute
+2 -2
View File
@@ -117,8 +117,8 @@ bool formatCompact(char* out, size_t capacity, unsigned slot, const Entry& entry
default: break;
}
if (entry.drop_on_match) text.append(" d");
else if (entry.scope_name[0]) text.append(" s=%s", entry.scope_name);
else if (entry.target_region_name[0]) text.append(" r=%s", entry.target_region_name);
else if (FloodRuleTargets::scopeName(entry)[0]) text.append(" s=%s", FloodRuleTargets::scopeName(entry));
else if (FloodRuleTargets::regionName(entry)[0]) text.append(" r=%s", FloodRuleTargets::regionName(entry));
if (entry.rate_limit_enabled) text.append(" q=%u", (unsigned)entry.rate_per_minute);
if (entry.priority) text.append(" pri=%u", (unsigned)entry.priority);
if (entry.stop_on_match) text.append(" s");
+2 -1
View File
@@ -4,6 +4,7 @@
#include <stdio.h>
#include <string.h>
#include <Packet.h>
#include "FloodRuleTargets.h"
#include "TxtDataHelpers.h"
#include "FloodTextPattern.h"
#include "FloodGroupPlainText.h"
@@ -80,7 +81,7 @@ inline bool validRuleFields(const Entry& entry) {
const bool content_valid = !contentRequested(entry.extra)
|| ((entry.channel_key_len == 16 || entry.channel_key_len == 32)
&& (entry.payload_type == 0xFF || entry.payload_type == PAYLOAD_TYPE_GRP_TXT));
const bool flood_actions = entry.scope_name[0] || entry.target_region_name[0]
const bool flood_actions = FloodRuleTargets::scopeName(entry)[0] || FloodRuleTargets::regionName(entry)[0]
|| entry.retry_on_match || entry.scope_uses_slow_timing;
return path_valid && content_valid && (!flood_actions
|| entry.extra.route == LegacyRoute || entry.extra.route == FloodRoute);
+32
View File
@@ -0,0 +1,32 @@
#pragma once
// Rule targets are mutually exclusive. The repeater stores one tagged name;
// the room engine and portable policy users retain their existing fields.
// Preserve array references so explicit FPF7 serialization keeps both fixed
// width fields, including the zero-filled inactive name.
namespace FloodRuleTargets {
template<typename Entry>
auto scopeNameImpl(const Entry& entry, int) -> decltype(entry.scopeName()) {
return entry.scopeName();
}
template<typename Entry>
auto scopeNameImpl(const Entry& entry, long) -> decltype((entry.scope_name)) {
return entry.scope_name;
}
template<typename Entry>
auto regionNameImpl(const Entry& entry, int) -> decltype(entry.regionName()) {
return entry.regionName();
}
template<typename Entry>
auto regionNameImpl(const Entry& entry, long) -> decltype((entry.target_region_name)) {
return entry.target_region_name;
}
template<typename Entry>
auto scopeName(const Entry& entry) -> decltype(scopeNameImpl(entry, 0)) {
return scopeNameImpl(entry, 0);
}
template<typename Entry>
auto regionName(const Entry& entry) -> decltype(regionNameImpl(entry, 0)) {
return regionNameImpl(entry, 0);
}
} // namespace FloodRuleTargets
+1 -1
View File
@@ -3,7 +3,7 @@
#include "OLEDControllerSettings.h"
#endif
#if defined(ESP32_PLATFORM) || (defined(NRF52_PLATFORM) && defined(RAK4631_COMBINED_ETHERNET))
#if defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)
// Adafruit_SSD1306::begin allocates WIDTH * ((HEIGHT + 7) / 8) once and
// reuses that framebuffer on later begin calls. The driver itself is embedded
// in the linked global display, not allocated on the heap. Even including
+50 -10
View File
@@ -438,7 +438,7 @@ int main() {
"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 * 336 + 16)
self.assertEqual(policy["components"]["flood_filter_table"], 63 * 304 + 16)
self.assertEqual(policy["components"]["ota_context"], 16384 + 16)
self.assertGreaterEqual(policy["required_contiguous_bytes"], 16384 + 16)
reduced = ram.requirements("STM32_PLATFORM", {
@@ -681,9 +681,9 @@ int main() {
self.assertNotIn("ota_encoder_workspace", components)
self.assertNotIn("ota_self_source_scratch", components)
self.assertEqual(components["display_pixels_and_driver"], 2048)
self.assertEqual(combined["required_heap_bytes"] - legacy["required_heap_bytes"], 3072 - 2048)
self.assertEqual(combined["required_heap_bytes"] - legacy["required_heap_bytes"], 3072)
self.assertEqual(components["loop_and_callback_stacks"], 8192 + 3072)
self.assertEqual(components["flood_filter_table"], 63 * 336 + 16)
self.assertEqual(components["flood_filter_table"], 63 * 304 + 16)
# An alternate port selects the same bridge instead of allocating two.
alternate = ram.requirements("NRF52_PLATFORM", {
**defines, "RAK4631_COMBINED_ETHERNET": 1,
@@ -794,7 +794,7 @@ int main() {
alias = ram.requirements("ESP32_PLATFORM", {"MESH_CLIENT_REPEATER_ONLY": 1},
"custom_companion_alias")
self.assertEqual(alias["components"]["client_table"], 32 * 284 + 16)
self.assertEqual(alias["components"]["flood_filter_table"], 63 * 336 + 16)
self.assertEqual(alias["components"]["flood_filter_table"], 63 * 304 + 16)
self.assertEqual(alias["components"]["neighbor_history"], 2048 * 9 + 16)
self.assertEqual(alias["components"]["radio_packet_pool"], 10240)
@@ -838,7 +838,46 @@ int main() {
self.assertEqual(legacy["required_contiguous_bytes"],
selected["required_contiguous_bytes"])
def test_combined_rak_oled_bound_does_not_change_other_display_budgets(self):
def test_rak_repeater_compact_rule_and_oled_budgets_keep_every_other_reserve(self):
flags = {
"ENABLE_OTA": 1, "OTA_RAK_AUTO_STORE": 1, "OTA_QSPI_STORE": 1,
"OTA_RAK15002_SD_STORE": 1, "OTA_FLASH_STORE": 1,
"MESHCORE_OTA_DEVICE_DEFLATE": 1, "MESH_CLIENT_REPEATER_ONLY": 1,
"DISPLAY_CLASS": "SSD1306Display",
}
selected = ram.requirements("NRF52_PLATFORM", flags, "RAK_4631_repeater")
self.assertEqual(selected["components"], {
"loop_and_callback_stacks": 11264,
"core_usb_filesystems_sensors": 12288,
"sd_fat_volume_and_files": 2048,
"radio_packet_pool": 10240,
"client_table": 9104,
"flood_filter_table": 63 * 304 + 16,
"ota_generic_source_buffers": 3616,
"ota_encoder_workspace": 1040,
"ota_self_source_scratch": 4128,
"display_pixels_and_driver": 2048,
"screen_objects_and_history": 2048,
"allocation_and_transient_margin": 4096,
})
self.assertEqual(selected["required_heap_bytes"], 81088)
self.assertEqual(selected["required_contiguous_bytes"], 19168)
# The fresh Full baseline's 81,668 bytes passed only after the real
# 32-byte per-row reduction and same-driver OLED accounting proof.
# Neither a one-byte deficit nor the old failed capacity can bypass
# the existing real-ELF gate.
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "firmware.elf"
for available, expected in ((81087, False), (81088, True), (81668, True)):
with self.subTest(available=available):
elf32(path, {"__HeapBase": (0x20010000, 0),
"__HeapLimit": (0x20010000 + available, 0)})
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
passed = ram.check_firmware(path, "NRF52_PLATFORM", "nrf52840",
flags, "RAK_4631_repeater") == 0
self.assertEqual(passed, expected)
def test_nrf52_oled_bound_matches_same_source_guard_without_changing_other_displays(self):
defines = {
"RAK_4631": 1, "ETHERNET_ENABLED": 1, "OTA_RAK_AUTO_STORE": 1,
"RAK4631_COMBINED_ETHERNET": 1,
@@ -853,7 +892,7 @@ int main() {
ordinary = ram.requirements("NRF52_PLATFORM", {
"DISPLAY_CLASS": "SSD1306Display",
}, "RAK_4631_repeater")
self.assertEqual(ordinary["components"]["display_pixels_and_driver"], 4096)
self.assertEqual(ordinary["components"]["display_pixels_and_driver"], 2048)
def test_esp32_fixed_oled_and_uart_bounds_preserve_full_runtime_reserves(self):
defines = {
@@ -866,13 +905,13 @@ int main() {
}
full = ram.requirements("ESP32_PLATFORM", defines, "Heltec_v3_repeater_observer_mqtt")
parts = full["components"]
self.assertEqual(full["required_heap_bytes"], 203920)
self.assertEqual(full["required_heap_bytes"], 201904)
self.assertEqual(parts["display_pixels_and_driver"], 2048)
self.assertEqual(parts["uart_bridge_and_driver"], 3072 + 4096)
self.assertEqual(parts["wireless_stacks"], 49152)
self.assertEqual(parts["mqtt_startup_allowance"], 24576)
self.assertEqual(parts["allocation_and_transient_margin"], 16384)
self.assertEqual(full["required_contiguous_bytes"], 21184)
self.assertEqual(full["required_contiguous_bytes"], 19168)
for driver, expected in (("SH1106Display", 2048), ("SH1107Display", 4096),
("ST7735Display", 25602)):
with self.subTest(driver=driver):
@@ -883,7 +922,7 @@ int main() {
self.assertEqual(other["components"], {
**parts, "display_pixels_and_driver": expected,
})
self.assertEqual(other["required_contiguous_bytes"], max(21184, expected))
self.assertEqual(other["required_contiguous_bytes"], max(19168, expected))
def test_sh1106_full_mqtt_release_profile_is_linked_in_ci(self):
workflow = (ROOT / ".github/workflows/run-unit-tests.yml").read_text()
@@ -933,7 +972,8 @@ int main() {
"-DWITH_RS232_BRIDGE=1", "-DWITH_UDP_BRIDGE=1", f"-DOBJECT_BYTES={size}",
f"-DFRAME_BYTES={frame}", *["-D" + flag for flag in platform],
], input=stand_in + prefix, text=True, capture_output=True)
enforced = "ESP32=1" in platform or (
enforced = "ESP32=1" in platform or ("NRF52_PLATFORM=1" in platform
and path.endswith("SSD1306Display.cpp")) or (
"RAK4631_COMBINED_ETHERNET=1" in platform
and not path.endswith(("SH1106Display.cpp", "UDPBridge.cpp")))
self.assertEqual(result.returncode == 0, bounded or not enforced,
+35 -1
View File
@@ -93,10 +93,17 @@ static const char RULE_TEMP_FILE[] = "/flood_filter.tmp";
static const char RULE_BACKUP_FILE[] = "/flood_filter.bak";
'''
SCENARIOS = r'''
template<class Entry>
auto setScope(Entry& row, const char* name, int) -> decltype(row.target_name, void()) {
row.target_is_region = false;
std::strcpy(row.target_name, name);
}
template<class Entry>
void setScope(Entry& row, const char* name, long) { std::strcpy(row.scope_name,name); }
template<class Entry> void canonicalRule(Entry& row) {
row.active=true;row.payload_type=PAYLOAD_TYPE_ADVERT;
row.min_hops=2;row.max_hops=63;row.suspend_on_temp_radio=true;
std::strcpy(row.scope_name,"#Town");row.match_blacklisted_path=true;
setScope(row,"#Town",0);row.match_blacklisted_path=true;
row.scope_uses_slow_timing=true;row.incoming_scope_kind=FloodFilterPolicy::RULE_IN_ALLOWED;
row.path_hash_size=3;row.rate_limit_enabled=true;row.rate_per_minute=1234;
row.priority=7;row.stop_on_match=true;row.retry_on_match=true;
@@ -216,6 +223,33 @@ int main() {
assert(writer.saveFloodPacketFilters());MyMesh drop(fs,63);assert(drop.loadFloodPacketFilters());
assert(drop.storage[0].active && drop.storage[0].drop_on_match && !drop.storage[0].retry_on_match);
}
{
MemoryFS fs;MyMesh writer(fs,63);auto& row=writer.storage[62];
row.active=true;row.payload_type=PAYLOAD_TYPE_ADVERT;row.max_hops=63;
row.target_is_region=true;
std::strcpy(row.target_name,"1234567890123456789012345678901");
assert(writer.saveFloodPacketFilters());
const auto image=fs.get(FLOOD_PACKET_FILTER_FILE);
MyMesh reader(fs,63);assert(reader.loadFloodPacketFilters());
assert(reader.storage[62].active && reader.storage[62].target_is_region);
assert(!std::strcmp(reader.storage[62].regionName(), row.target_name));
assert(reader.storage[62].scopeName()[0]==0);
assert(reader.saveFloodPacketFilters() && fs.get(FLOOD_PACKET_FILTER_FILE)==image);
// The stored region field starts at byte 153 of a 187-byte FPF7 row.
// A direct scope and region target in one old-format row is invalid;
// compaction must never silently choose one.
const size_t stride=(image.size()-5-9)/63;
assert(stride==187);
for(bool terminate : {true,false}) {
auto malformed=image;
const size_t scope=5+62*stride+5;
malformed[scope]='#';malformed[scope+1]='X';
if(!terminate)std::fill_n(malformed.begin()+scope,32,'X');
MemoryFS bad;bad.put(FLOOD_PACKET_FILTER_FILE,malformed);
MyMesh boot(bad,63);assert(boot.loadFloodPacketFilters());
assert(boot.seeded==1 && !boot.storage[62].active);
}
}
const auto large=fixture(63,62),small=fixture(4,2);
// Older writers stored every slot, including inactive trailing entries.
// Build the exact production wire format and replace the final active row
+34 -5
View File
@@ -24,8 +24,8 @@ static constexpr uint8_t NAME_LEN = 32, PATH_PREFIX_BYTES_MAX = 9;
'''
SCENARIOS = r'''
static_assert(sizeof(RepeaterEntry) == 332 && sizeof(RoomEntry) == 332,
"extended rows retain the reviewed 332-byte RAM bound");
static_assert(sizeof(RepeaterEntry) == 300 && sizeof(RoomEntry) == 332,
"compact repeater rows save 32 bytes; room layout stays unchanged");
static_assert(sizeof(LegacyEntry) == 40, "retain legacy STM32 rule layout");
static_assert(alignof(RepeaterEntry) >= alignof(uint32_t));
static_assert(alignof(RoomEntry) >= alignof(uint32_t));
@@ -37,6 +37,16 @@ static_assert(alignof(RoomEntry) >= alignof(uint32_t));
CHECK_ALIGNED(RepeaterEntry);
CHECK_ALIGNED(RoomEntry);
template<class Entry>
auto setTargetByte(Entry& e, bool region, size_t offset, char value, int)
-> decltype(e.target_name, void()) {
e.target_is_region = region;
e.target_name[offset] = value;
}
template<class Entry>
void setTargetByte(Entry& e, bool region, size_t offset, char value, long) {
(region ? e.target_region_name : e.scope_name)[offset] = value;
}
template<class Entry> void setFlags(Entry& e, unsigned flags) {
e.active = flags & 1;
e.suspend_on_temp_radio = flags & 2;
@@ -108,7 +118,7 @@ template<class Entry> void exerciseConfigurationEquality() {
case 2: changed.min_hops = 1; break;
case 3: changed.max_hops = 1; break;
case 4: changed.suspend_on_temp_radio = true; break;
case 5: changed.scope_name[sizeof(changed.scope_name)-1] = 1; break;
case 5: setTargetByte(changed, false, 31, 1, 0); break;
case 6: changed.match_blacklisted_path = true; break;
case 7: changed.scope_uses_slow_timing = true; break;
case 8: changed.incoming_scope_kind = 1; break;
@@ -120,7 +130,7 @@ template<class Entry> void exerciseConfigurationEquality() {
case 14: changed.path_hash_size = 1; break;
case 15: changed.path_hops = 1; break;
case 16: changed.path[sizeof(changed.path)-1] = 1; break;
case 17: changed.target_region_name[sizeof(changed.target_region_name)-1] = 1; break;
case 17: setTargetByte(changed, true, 31, 1, 0); break;
case 18: changed.drop_on_match = true; break;
case 19: changed.rate_limit_enabled = false; break;
case 20: changed.rate_per_minute = 6; break;
@@ -128,7 +138,7 @@ template<class Entry> void exerciseConfigurationEquality() {
case 22: changed.stop_on_match = true; break;
case 23: changed.retry_on_match = true; break;
case 24: changed.transport_modes = 2; break;
case 25: changed.scope_name[0] = '#'; break;
case 25: setTargetByte(changed, false, 0, '#', 0); break;
}
assert(!FloodFilterPolicy::sameRuleConfiguration(original, changed));
assert(!FloodFilterPolicy::sameRuleConfiguration(changed, original));
@@ -171,6 +181,25 @@ template<class Rules, class Entry> void exerciseProductionSlotSelection() {
assert(rules.select(candidate, 3) == 3); // Explicit index remains authoritative.
}
int main() {
// Every old flag combination can coexist with either target kind. Neither
// the tag nor name views may corrupt independent flags or leak a stale name.
for (unsigned flags = 0; flags < 512; ++flags) {
for (unsigned region = 0; region < 2; ++region) {
RepeaterEntry e{};
setFlags(e, flags);
e.target_is_region = region;
std::strcpy(e.target_name, "1234567890123456789012345678901");
assert(flagsOf(e) == flags);
const auto& selected = region ? e.regionName() : e.scopeName();
const auto& inactive = region ? e.scopeName() : e.regionName();
assert(!std::strcmp(selected, e.target_name));
for (char byte : inactive) assert(byte == 0);
RepeaterEntry copied = e;
assert(copied.target_is_region == region);
assert(!std::memcmp(copied.target_name, e.target_name, 32));
assert(flagsOf(copied) == flags);
}
}
exerciseFlags<RepeaterEntry>();
exerciseFlags<RoomEntry>();
exerciseConfigurationEquality<RepeaterEntry>();
+6
View File
@@ -139,6 +139,12 @@ int main() {
{ Fixture f;f.radio.receiving=true;assert(f.d.queue(bytes));f.tick();f.tick(201);
assert(f.radio.sends==0 && f.d.hasOutbound());f.radio.receiving=false;f.tick(201);assert(f.radio.sends==1);
}
{ Fixture f;f.radio.receiving=true;assert(f.d.queue(bytes));f.tick();
// The empty MeshCore queue must not retire a foreign frame's live CAD
// wait. Its existing bounded escape still records sustained contention.
f.tick(201);assert(!f.radio.sends && f.d.hasOutbound());
f.tick(3800);assert(f.radio.sends==1 && (f.d.getErrFlags() & ERR_EVENT_CAD_TIMEOUT));
}
{ Fixture f;auto* p=newPacket(f.d);f.radio.p.cross=RadioCrossMode::On;
assert(!f.radio.p.canCross() && f.d.getTransmitProfileMask(p)==1);
assert(f.d.sendPacket(p,0));assert(f.manager.getOutboundTotal()==1);f.tick();
+24
View File
@@ -286,6 +286,30 @@ int main() {
assert(f.d.getTotalAirTime()==1151 && !f.d.getNumSentDirect() && f.d.done.empty());
}
}
'''
self.compile_run(code, ["-DENABLE_OTA", "-DMESH_ENABLE_MESHTEXT_CHAT=0",
"-DMESH_ENABLE_ETHERNET_TUNNEL=0"], dispatcher=True)
def test_channel_wait_retirement_preserves_direct_ota_owner(self):
code = dispatcher_harness() + r'''
int main() {
{ Fixture f;directProfile(f);f.radio.receiving=true;
auto* old=newPacket(f.d,RADIO_TX_SECONDARY);assert(f.d.sendPacket(old,0));
f.tick();assert(!f.radio.sends && f.manager.getOutboundTotal()==1);
f.manager.free(f.manager.removeOutboundByIdx(0));
f.clock.now=10000;assert(enqueue(f));f.tick();
// New raw/direct ownership cannot inherit a cancelled MeshCore wait.
assert(!f.radio.sends && !(f.d.getErrFlags() & ERR_EVENT_CAD_TIMEOUT));
}
{ Fixture f;directProfile(f);f.radio.receiving=true;assert(enqueue(f));
f.tick();assert(!f.radio.sends && !f.manager.getOutboundTotal());
f.clock.now=10000;auto* next=newPacket(f.d,RADIO_TX_SECONDARY);
assert(f.d.sendPacket(next,0));f.tick();
// Conversely, a still-owned direct frame keeps its original ceiling even
// when another queued packet arrives for the same profile.
assert(f.radio.sends==1 && (f.d.getErrFlags() & ERR_EVENT_CAD_TIMEOUT));
}
}
'''
self.compile_run(code, ["-DENABLE_OTA", "-DMESH_ENABLE_MESHTEXT_CHAT=0",
"-DMESH_ENABLE_ETHERNET_TUNNEL=0"], dispatcher=True)
@@ -1317,6 +1317,190 @@ TEST(Dispatcher, GrowingQueueShortensCADBusyCeiling) {
EXPECT_TRUE(dispatcher.getErrFlags() & ERR_EVENT_CAD_TIMEOUT);
}
TEST(Dispatcher, CancelledBusyQueueDoesNotAgeLaterChannelWait) {
RxReservePacketManager manager(8, 1);
TestClock clock;
clock.now = 100;
TestRadio radio;
radio.receiving = true;
TestDispatcher dispatcher(radio, clock, manager);
dispatcher.begin();
auto queuePacket = [&]() {
mesh::Packet* packet = dispatcher.obtainNewPacket();
EXPECT_NE(packet, nullptr);
packet->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT);
packet->payload[0] = 0x40;
packet->payload_len = 1;
EXPECT_TRUE(dispatcher.sendPacket(packet, 0));
};
queuePacket();
clock.now = 101;
dispatcher.loop();
EXPECT_EQ(0, radio.send_starts);
// Echo suppression, cancellation and stale-work expiry can retire the last
// queued packet without a successful send or a free-channel observation.
manager.free(manager.removeOutboundByIdx(0));
clock.now = 302;
dispatcher.loop();
EXPECT_EQ(0, manager.getOutboundTotal());
clock.now = 10000;
queuePacket();
dispatcher.loop();
EXPECT_EQ(0, radio.send_starts);
EXPECT_EQ(0, dispatcher.getErrFlags() & ERR_EVENT_CAD_TIMEOUT);
// A genuinely continuous busy channel still reaches the original ceiling.
clock.now = 14001;
dispatcher.loop();
EXPECT_EQ(1, radio.send_starts);
EXPECT_TRUE(dispatcher.getErrFlags() & ERR_EVENT_CAD_TIMEOUT);
}
TEST(Dispatcher, BeginStartsFreshCADBusyObservation) {
RxReservePacketManager manager(8, 1);
TestClock clock;
clock.now = 100;
TestRadio radio;
radio.receiving = true;
TestDispatcher dispatcher(radio, clock, manager);
dispatcher.begin();
mesh::Packet* packet = dispatcher.obtainNewPacket();
ASSERT_NE(packet, nullptr);
packet->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT);
packet->payload[0] = 0x40;
packet->payload_len = 1;
ASSERT_TRUE(dispatcher.sendPacket(packet, 0));
clock.now = 101;
dispatcher.loop();
EXPECT_EQ(0, radio.send_starts);
clock.now = 10000;
dispatcher.begin();
dispatcher.loop();
EXPECT_EQ(0, radio.send_starts);
EXPECT_EQ(0, dispatcher.getErrFlags() & ERR_EVENT_CAD_TIMEOUT);
clock.now = 14001;
dispatcher.loop();
EXPECT_EQ(1, radio.send_starts);
EXPECT_TRUE(dispatcher.getErrFlags() & ERR_EVENT_CAD_TIMEOUT);
}
TEST(Dispatcher, ImmediateReplacementStartsFreshCADBusyObservation) {
RxReservePacketManager manager(8, 1);
TestClock clock;
clock.now = 100;
TestRadio radio;
radio.receiving = true;
TestDispatcher dispatcher(radio, clock, manager);
dispatcher.begin();
auto queuePacket = [&]() {
mesh::Packet* packet = dispatcher.obtainNewPacket();
EXPECT_NE(packet, nullptr);
packet->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT);
packet->payload[0] = 0x40;
packet->payload_len = 1;
EXPECT_TRUE(dispatcher.sendPacket(packet, 0));
};
queuePacket();
clock.now = 101;
dispatcher.loop();
EXPECT_EQ(0, radio.send_starts);
// There need not be an intervening Dispatcher loop with an empty queue.
// A cancellation callback can enqueue the replacement immediately.
clock.now = 10000;
manager.free(manager.removeOutboundByIdx(0));
queuePacket();
dispatcher.loop();
EXPECT_EQ(0, radio.send_starts);
EXPECT_EQ(0, dispatcher.getErrFlags() & ERR_EVENT_CAD_TIMEOUT);
}
TEST(Dispatcher, OtherProfilesQueuedWorkDoesNotPreserveRetiredCADWait) {
class DualProfileRadio : public TestRadio {
public:
mesh::RadioProfiles config;
DualProfileRadio() {
config.secondary.params = config.primary;
config.secondary.mode = mesh::RadioProfileMode::RxTx;
}
mesh::RadioProfiles* profiles() override { return &config; }
const mesh::RadioProfiles* profiles() const override { return &config; }
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t) override {
return mesh::RadioParamApplyResult::APPLIED;
}
} radio;
RxReservePacketManager manager(8, 1);
TestClock clock;
clock.now = 100;
radio.receiving = true;
TestDispatcher dispatcher(radio, clock, manager);
dispatcher.begin();
auto queuePacket = [&](uint8_t policy, uint32_t delay = 0) {
mesh::Packet* packet = dispatcher.obtainNewPacket();
EXPECT_NE(packet, nullptr);
packet->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT);
packet->payload[0] = 0x40;
packet->payload_len = 1;
packet->tx_radio = policy;
EXPECT_TRUE(dispatcher.sendPacket(packet, 0, delay));
};
queuePacket(mesh::RADIO_TX_SECONDARY);
clock.now = 101;
dispatcher.loop();
EXPECT_EQ(0, radio.send_starts);
manager.free(manager.removeOutboundByIdx(0));
// Future work on primary keeps the whole queue nonempty, but cannot own
// the secondary profile's previous, cancelled channel wait.
queuePacket(mesh::RADIO_TX_PRIMARY, 100000);
clock.now = 10000;
queuePacket(mesh::RADIO_TX_SECONDARY);
dispatcher.loop();
EXPECT_EQ(0, radio.send_starts);
EXPECT_EQ(0, dispatcher.getErrFlags() & ERR_EVENT_CAD_TIMEOUT);
}
TEST(Dispatcher, NewQueuedWorkPreservesRetainedRetryCADWait) {
RxReservePacketManager manager(8, 1);
TestClock clock;
clock.now = 100;
TestRadio radio;
radio.start_success = false;
TestDispatcher dispatcher(radio, clock, manager);
dispatcher.begin();
auto queuePacket = [&]() {
mesh::Packet* packet = dispatcher.obtainNewPacket();
EXPECT_NE(packet, nullptr);
packet->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT);
packet->payload[0] = 0x40;
packet->payload_len = 1;
EXPECT_TRUE(dispatcher.sendPacket(packet, 0));
};
queuePacket();
clock.now = 101;
dispatcher.loop();
EXPECT_EQ(1, radio.send_starts);
EXPECT_EQ(0, manager.getOutboundTotal());
radio.receiving = true;
clock.now = 302;
dispatcher.loop();
EXPECT_EQ(1, radio.send_starts);
clock.now = 5000;
queuePacket();
radio.start_success = true;
dispatcher.loop();
EXPECT_EQ(2, radio.send_starts);
EXPECT_TRUE(dispatcher.getErrFlags() & ERR_EVENT_CAD_TIMEOUT);
}
TEST(Dispatcher, SilentRadioEscalatesFromSoftToHardRecovery) {
RxReservePacketManager manager(8, 4);
TestClock clock;