diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 61743feeb..a51c12183 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/docs/research/firmware_memory_budget.md b/docs/research/firmware_memory_budget.md index 269204a90..48ae45fb8 100644 --- a/docs/research/firmware_memory_budget.md +++ b/docs/research/firmware_memory_budget.md @@ -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. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index b8f927112..aaf408b9e 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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"); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 00a1d7401..15450ac99 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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 (®ionName() 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 diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index ce260de52..20095b267 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -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 diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 5a8b4605e..aa937f256 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -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 { diff --git a/src/Dispatcher.h b/src/Dispatcher.h index dc18cb1b1..c715fcd06 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -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; diff --git a/src/helpers/FloodFilterPolicy.h b/src/helpers/FloodFilterPolicy.h index df00a9c73..0ec9ed444 100644 --- a/src/helpers/FloodFilterPolicy.h +++ b/src/helpers/FloodFilterPolicy.h @@ -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 diff --git a/src/helpers/FloodRuleCLI.h b/src/helpers/FloodRuleCLI.h index 9a6bb82e3..3cbbe155d 100644 --- a/src/helpers/FloodRuleCLI.h +++ b/src/helpers/FloodRuleCLI.h @@ -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"); diff --git a/src/helpers/FloodRuleExtensions.h b/src/helpers/FloodRuleExtensions.h index bd3b8358c..2251b4094 100644 --- a/src/helpers/FloodRuleExtensions.h +++ b/src/helpers/FloodRuleExtensions.h @@ -4,6 +4,7 @@ #include #include #include +#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); diff --git a/src/helpers/FloodRuleTargets.h b/src/helpers/FloodRuleTargets.h new file mode 100644 index 000000000..867281773 --- /dev/null +++ b/src/helpers/FloodRuleTargets.h @@ -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 +auto scopeNameImpl(const Entry& entry, int) -> decltype(entry.scopeName()) { + return entry.scopeName(); +} +template +auto scopeNameImpl(const Entry& entry, long) -> decltype((entry.scope_name)) { + return entry.scope_name; +} +template +auto regionNameImpl(const Entry& entry, int) -> decltype(entry.regionName()) { + return entry.regionName(); +} +template +auto regionNameImpl(const Entry& entry, long) -> decltype((entry.target_region_name)) { + return entry.target_region_name; +} +template +auto scopeName(const Entry& entry) -> decltype(scopeNameImpl(entry, 0)) { + return scopeNameImpl(entry, 0); +} +template +auto regionName(const Entry& entry) -> decltype(regionNameImpl(entry, 0)) { + return regionNameImpl(entry, 0); +} +} // namespace FloodRuleTargets diff --git a/src/helpers/ui/SSD1306Display.cpp b/src/helpers/ui/SSD1306Display.cpp index e066e6993..655801434 100644 --- a/src/helpers/ui/SSD1306Display.cpp +++ b/src/helpers/ui/SSD1306Display.cpp @@ -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 diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 28f8a2b2a..837e40a86 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -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, diff --git a/test/test_flood_policy_capacity.py b/test/test_flood_policy_capacity.py index 1b14bcf0e..ad98aa0c1 100644 --- a/test/test_flood_policy_capacity.py +++ b/test/test_flood_policy_capacity.py @@ -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 +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 +void setScope(Entry& row, const char* name, long) { std::strcpy(row.scope_name,name); } template 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 diff --git a/test/test_flood_rule_layout.py b/test/test_flood_rule_layout.py index 24890789a..0a8aa070d 100644 --- a/test/test_flood_rule_layout.py +++ b/test/test_flood_rule_layout.py @@ -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 +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 +void setTargetByte(Entry& e, bool region, size_t offset, char value, long) { + (region ? e.target_region_name : e.scope_name)[offset] = value; +} template void setFlags(Entry& e, unsigned flags) { e.active = flags & 1; e.suspend_on_temp_radio = flags & 2; @@ -108,7 +118,7 @@ template 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 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 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 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(); exerciseFlags(); exerciseConfigurationEquality(); diff --git a/test/test_meshtext_transport.py b/test/test_meshtext_transport.py index 3c26e3000..7ed38927c 100644 --- a/test/test_meshtext_transport.py +++ b/test/test_meshtext_transport.py @@ -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(); diff --git a/test/test_ota_kiss_direct.py b/test/test_ota_kiss_direct.py index 2433b7f4d..b12673672 100644 --- a/test/test_ota_kiss_direct.py +++ b/test/test_ota_kiss_direct.py @@ -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) diff --git a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp index 6e11a404c..c73a02d27 100644 --- a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp +++ b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp @@ -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;