diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml
index 6fe065f1a..112fd5360 100644
--- a/.github/workflows/run-unit-tests.yml
+++ b/.github/workflows/run-unit-tests.yml
@@ -391,6 +391,9 @@ jobs:
python3 -B test/test_flood_retry_bucket_cli.py -v
python3 -B test/test_flood_advert_rtc.py -v
python3 -B test/test_flood_advert_iram.py -v
+ python3 -B test/test_flood_advert_psram.py -v
+ python3 -B test/test_repeater_scope_psram.py -v
+ python3 -B test/test_ws_transport_psram.py -v
python3 -B test/test_ota_security.py -v
python3 -B test/test_companion_offline_delivery.py -v
python3 -B test/test_room_receive_admission.py -v
diff --git a/docs/WiFi.md b/docs/WiFi.md
index 69d083a56..38ab3b4f0 100644
--- a/docs/WiFi.md
+++ b/docs/WiFi.md
@@ -886,6 +886,12 @@ send retries only that broker every 30 seconds; successful brokers keep their
normal status interval. Each slot has a stable, distinct client ID, including
when two slots point at the same broker.
+G3 also places the SDK's two padded WebSocket buffers per initialized MQTT
+client in PSRAM, saving 2,050 internal bytes per client when PSRAM allocation
+succeeds. G3 repeaters move their complete scope tables and advert-limit
+history there too. Capacities, saved formats, task stacks and DMA reserves
+remain unchanged; safe internal fallback is retained.
+
Outbox diagnostics are cached by the network owner, so `get mqtt.stats` does
not wait for an SDK mutex held by a network write. `rec:N/write` records a
failed-write recovery; `no errors` describes the current connected session,
diff --git a/docs/research/firmware_memory_budget.md b/docs/research/firmware_memory_budget.md
index 46a615156..817c8b80e 100644
--- a/docs/research/firmware_memory_budget.md
+++ b/docs/research/firmware_memory_budget.md
@@ -190,6 +190,23 @@ other tasks. Available linked internal heap cannot establish how much
DMA-capable heap remains or how it is fragmented after boot. Only runtime
observations can establish that.
+G3 repeater builds also place their channel/scope tables and full advert-source
+history in PSRAM. Their capacities and persisted formats remain unchanged.
+The RAM check still reserves internal fallback for both complete tables;
+moving them out of BSS does not relax startup admission or the DMA reserve.
+If neither heap can safely hold a complete table, startup reports the failed
+allocation and stops rather than running with reduced filtering protection.
+Other targets keep their existing storage. `MESH_REPEATER_PSRAM_TABLES=0`
+restores inline storage for a custom G3 build.
+
+G3's IDF 4 WebSocket wrapper also prefers PSRAM for its two padded 1,025-byte
+SDK buffers per initialized MQTT client, including clients using plain MQTT.
+Five initialized clients move 10,250 bytes of payload from internal RAM.
+Transport objects, task stacks, DMA buffers and radio packet queues keep their
+existing placement. The existing extra-byte overflow fix and guarded internal
+fallback remain in force. These figures describe allocation placement, not a
+measurement of running free heap or qualification of five simultaneous brokers.
+
Maximum-load qualification still needs physical boot and sustained reconnect,
publish and configuration-change tests with the selected number of brokers,
transport schemes, certificates and enabled UART/browser/OTA features. Record
diff --git a/docs/test-results/g3_psram_storage_2026-10-27.md b/docs/test-results/g3_psram_storage_2026-10-27.md
new file mode 100644
index 000000000..4f3a12e93
--- /dev/null
+++ b/docs/test-results/g3_psram_storage_2026-10-27.md
@@ -0,0 +1,83 @@
+
+
View this page on MeshCore Docs.
+
+
+# G3 repeater PSRAM storage, 2026-10-27
+
+The G3 Full repeater frees **36,776 bytes of static internal RAM** by moving
+its channel/scope tables and advert-limit history to PSRAM. The MQTT SDK
+WebSocket buffers also prefer PSRAM: **2,050 bytes per initialized client**,
+or 10,250 bytes with five initialized clients. Capacities, saved formats and
+radio queues remain unchanged.
+
+These are linked measurements and verified allocation sizes. No G3 was
+flashed; running heap, RF timing and sustained broker load were not measured.
+The [machine-readable evidence](g3_psram_storage_2026-10-27.safe.json) records
+the matched builds, hashes and checks.
+
+## Matched builds
+
+Baseline: `88bfaebb5`. Final: the production changes accompanying this report.
+Both use the same G3 Full recipe, version, Cascade profile and USA preset.
+
+| Measurement | Baseline | Final |
+| --- | ---: | ---: |
+| Internal BSS bytes | 142,088 | 105,312 |
+| Internal data bytes | 23,980 | 23,980 |
+| Linked internal heap capacity | 199,976 | 236,752 |
+| Modeled internal requirement | 197,808 | 234,636 |
+| Conservative admission margin | 2,168 | 2,116 |
+| Packaged application bytes | 2,156,504 | 2,155,752 |
+| OTA application-slot bytes | 6,553,600 | 6,553,600 |
+
+The model still reserves complete internal fallback for both tables: 18,892
+bytes for scope storage and 17,936 for advert history, including overhead.
+That explains why the conservative admission margin does not rise with the
+normal PSRAM-backed free heap. The 16 KiB DMA reserve and existing startup
+requirements are unchanged. All 16 capability checks pass, with no declared
+feature reductions; WiFi/LoRa OTA, MQTT, logging, WebConfig, bridges and host
+KISS remain compiled in.
+
+## Allocation behavior
+
+- G3 repeater defaults select PSRAM for all 255 scope entries, 32 direct-scope
+ names, 255 scope requirements and 128 advert-source histories.
+- Allocation happens once during construction. Array references preserve
+ addresses, complete-array `sizeof`, file formats and rollback behavior.
+- The pinned ESP32-S3 SDK initializes PSRAM heaps before global constructors;
+ Arduino's later `psramFound()` flag is not used as a constructor-time gate.
+- Complete internal fallback is allowed only while preserving the existing
+ DMA reserve. Otherwise startup reports the allocation failure and stops.
+ It never silently reduces filter or advert-protection capacity.
+- `MESH_REPEATER_PSRAM_TABLES=0` restores inline storage. Other boards retain
+ their existing default storage, including RTC/IRAM paths.
+- The IDF 4 SDK initializes both WS and WSS transports for each MQTT client,
+ even plain MQTT. Both buffers retain the existing 1,025-byte overflow fix.
+ A failed padded allocation destroys that transport safely instead of
+ returning its original undersized buffer. IDF 5 remains a passthrough.
+
+## Verification
+
+- Scope storage: three ASan/UBSan tests cover full capacities, identical 18,885
+ persisted bytes, rollback on short writes/rename failures, lifetime, guarded
+ fallback, fatal allocation failure and platform/SDK gates.
+- Advert history: four ASan/UBSan tests compare all 128 entries against inline
+ storage, including quota, duplicate, badlist and rollover behavior. Existing
+ RTC (eight), IRAM (seven) and native limiter (33 per backend) tests pass.
+- WebSocket wrapper: three ASan/UBSan tests exercise 17 scenarios across G3,
+ other PSRAM boards, internal-only boards and IDF 4/5, including the extra
+ byte, cleanup, parent lifetime and fallback boundaries.
+- RAM policy: all 63 tests pass, including six new fallback regressions.
+ Existing policy capacity, rule layout, moderation and CLI tests pass.
+- Canonical G3 Full package: compile, internal-RAM admission, dual-OTA fit and
+ capability checks pass. PlatformIO ran one process at a time.
+
+Reproduce the final build with:
+
+```sh
+bash build.sh build-firmware Station_G3_ESP32_repeater --full-exact --firmware-version v1.18.0.2-psram-test --profile cascade --radio-preset usa-cascade-fixed --require-ota
+```
+
+Physical acceptance remains a G3 boot, persistence, radio traffic and MQTT
+reconnect/load run with internal/DMA heap monitoring. The maximum five-broker
+TLS/WSS/outbox workload remains unqualified by these software checks.
diff --git a/docs/test-results/g3_psram_storage_2026-10-27.safe.json b/docs/test-results/g3_psram_storage_2026-10-27.safe.json
new file mode 100644
index 000000000..cfad93728
--- /dev/null
+++ b/docs/test-results/g3_psram_storage_2026-10-27.safe.json
@@ -0,0 +1,512 @@
+{
+ "schema_version": 1,
+ "baseline_commit": "88bfaebb50cd932bca126675a22be72ee57c0f2d",
+ "target": "Station_G3_ESP32_repeater",
+ "physical_validation_performed": false,
+ "maximum_runtime_load_qualified": false,
+ "command": "bash build.sh build-firmware Station_G3_ESP32_repeater --full-exact --firmware-version v1.18.0.2-psram-test --profile cascade --radio-preset usa-cascade-fixed --require-ota",
+ "net_internal_bss_bytes_freed": 36776,
+ "websocket_payload_bytes_per_initialized_client": 2050,
+ "websocket_payload_bytes_for_five_initialized_clients": 10250,
+ "limitations": [
+ "Linked capacity is not running free heap.",
+ "No G3 hardware flashed or measured.",
+ "No physical RF timing, persistence or broker-soak qualification."
+ ],
+ "builds": {
+ "baseline": {
+ "memory": {
+ "schema_version": 1,
+ "target": "Station_G3_ESP32_repeater",
+ "platform": "ESP32_PLATFORM",
+ "mcu": "esp32s3",
+ "elf_sha256": "f434ffa380fd55b77abfa67bf0195cd6af5ae0f5409f91a010c31b28cfe6251a",
+ "passed": true,
+ "available_internal_bytes": 199976,
+ "largest_internal_region_bytes": 167208,
+ "required_heap_bytes": 197808,
+ "required_contiguous_bytes": 19168,
+ "components": {
+ "core_tasks_usb_filesystems": 24576,
+ "wireless_stacks": 49152,
+ "mqtt_startup_allowance": 24576,
+ "udp_bridge_and_socket": 5120,
+ "ota_source_scratch": 8192,
+ "browser_terminal_session": 2048,
+ "neighbor_history": 18448,
+ "host_kiss_codec": 2048,
+ "radio_packet_pool": 10240,
+ "client_table": 9104,
+ "flood_filter_table": 19168,
+ "ota_generic_source_buffers": 3616,
+ "ota_encoder_workspace": 1040,
+ "display_pixels_and_driver": 2048,
+ "screen_objects_and_history": 2048,
+ "allocation_and_transient_margin": 16384
+ },
+ "display": "SH1106Display",
+ "full_companion": false,
+ "mqtt": {
+ "budget_scope": "startup minimum with runtime connection admission",
+ "startup_allowance_bytes": 24576,
+ "startup_active_slot_allowance": 2,
+ "runtime_slot_count": 6,
+ "maximum_active_slots": 5,
+ "maximum_active_slots_without_psram": 2,
+ "worker_stack_bytes": 8192,
+ "client_task_stack_bytes": 6144,
+ "client_task_priority": 5,
+ "client_rx_tx_buffer_bytes": 1792,
+ "maximum_slot_stack_buffer_allowance_bytes": 48384,
+ "cold_start_allocation_allowance_bytes": 16384,
+ "cold_start_dma_admission_free_bytes": 32768,
+ "cold_start_dma_admission_contiguous_bytes": 8192,
+ "additional_broker_cold_start_dma_admission_contiguous_bytes": 20480,
+ "restart_allocation_allowance_bytes": 8192,
+ "restart_dma_admission_free_bytes": 24576,
+ "restart_dma_admission_contiguous_bytes": 8192,
+ "additional_broker_restart_dma_admission_contiguous_bytes": 12288,
+ "dma_admission_free_bytes": 16384,
+ "dma_admission_contiguous_bytes": 4096,
+ "publish_contiguous_bytes": 4096,
+ "publish_contiguous_without_psram_bytes": 8000,
+ "publish_contiguous_with_psram_bytes": 4096,
+ "tls_psram_admission_contiguous_bytes": 16000,
+ "additional_broker_cold_start_tls_psram_admission_contiguous_bytes": 32384,
+ "additional_broker_restart_tls_psram_admission_contiguous_bytes": 24192,
+ "additional_broker_tls_psram_admission_contiguous_bytes": 16000,
+ "tls_internal_admission_free_bytes": 61440,
+ "tls_record_allocation_bytes": 17408,
+ "additional_broker_cold_start_tls_internal_admission_contiguous_bytes": 41792,
+ "additional_broker_restart_tls_internal_admission_contiguous_bytes": 33600,
+ "tls_internal_admission_contiguous_bytes": 17408,
+ "additional_broker_tls_internal_admission_contiguous_bytes": 25408,
+ "maximum_runtime_load_qualified": false,
+ "physical_validation_performed": false,
+ "limitations": "Maximum TLS/WSS/outbox workload is not covered by the startup allowance; runtime free DMA heap and fragmentation must satisfy connection admission; additional brokers reserve publication headroom beside startup allocations."
+ },
+ "regions": [
+ {
+ "start": 1070336488,
+ "end": 1070503696
+ },
+ {
+ "start": 1070530560,
+ "end": 1070563328
+ }
+ ],
+ "scope": "Linked capacity before runtime allocation; physical boot/load/soak validation remains required.",
+ "artifact_target": "Station_G3_ESP32_repeater",
+ "pio_environment": "Station_G3_ESP32_repeater_observer_mqtt",
+ "files": {
+ "Station_G3_ESP32_repeater-full-usb-wifi-ota-v1.18.0.2-psram-test-88bfaebb.bin": "bf8b4130a79beba18faa95e5f0966d07112c2131c9bcb87915e8b070b387adae",
+ "Station_G3_ESP32_repeater-full-usb-wifi-ota-v1.18.0.2-psram-test-88bfaebb-merged.bin": "defb82a9007799ebb03912536948cc99c3d69f9366bd24898c679c7aa3e8d7c0",
+ "Station_G3_ESP32_repeater-full-usb-wifi-ota-v1.18.0.2-psram-test-88bfaebb.capabilities.json": "b2d1879bd5cbed1459cbd56f066af9a2951493263aa782a57fe4610222400e70",
+ "Station_G3_ESP32_repeater-full-usb-wifi-ota-v1.18.0.2-psram-test-88bfaebb.meshtext-license.txt": "c37d0a453a2491514bac33ed835c1877f207390f134ae439a97b06b004e236d2"
+ }
+ },
+ "capabilities": {
+ "artifact_target": "Station_G3_ESP32_repeater",
+ "build_profile": "full",
+ "build_recipe": {
+ "schema_version": 1,
+ "sha256": "2db8b799c57e5b9ff224b8fec01bbd1ea1b425188e80d6a9e9b75a2b5658e3ff"
+ },
+ "capabilities": [
+ "bridge.espnow",
+ "bridge.udp",
+ "profile.full",
+ "release.full.same_partition_successor",
+ "radio2.kiss.host",
+ "radio2.kiss.usb",
+ "radio2.kiss.tcp",
+ "ota.update.wifi",
+ "ota.update.lora",
+ "cli.retry_preset",
+ "telemetry.history",
+ "ota.cli",
+ "web.webconfig",
+ "logging.usb.packets",
+ "logging.usb.control",
+ "logging.usb.output",
+ "bridge.udp.configuration",
+ "radio.station_g3.auto_slots"
+ ],
+ "ota_update_evidence": "firmware fits both OTA application slots; otadata present",
+ "ota_update_methods": [
+ "wifi",
+ "lora"
+ ],
+ "ota_update_verified": true,
+ "platform": "ESP32_PLATFORM",
+ "platformio_env": "Station_G3_ESP32_repeater_observer_mqtt",
+ "reductions": [],
+ "schema_version": 2,
+ "target": "Station_G3_ESP32_repeater",
+ "third_party_notices": [
+ {
+ "component": "espp-meshtext",
+ "file": "Station_G3_ESP32_repeater-full-usb-wifi-ota-v1.18.0.2-psram-test-88bfaebb.meshtext-license.txt",
+ "license": "MIT",
+ "sha256": "c37d0a453a2491514bac33ed835c1877f207390f134ae439a97b06b004e236d2"
+ }
+ ],
+ "verification": [
+ {
+ "capability": "radio2.kiss.host",
+ "evidence": "_ZN4mesh8hostkiss5Codec4feedEh; _ZN4mesh8hostkiss5Codec11queueOutputEhPKhj; _ZN4mesh10Dispatcher22queueForeignRadioFrameEPKhjhjNS_20RadioProfileProtocolE",
+ "present": true,
+ "source": "linked ELF symbols"
+ },
+ {
+ "capability": "radio2.kiss.usb",
+ "evidence": "Error: use set kiss on|off; Error: use set kiss.transport usb|tcp; USB: send KISS FEND to start",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "radio2.kiss.tcp",
+ "evidence": "station TCP 5004",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "bridge.espnow",
+ "evidence": "_ZN12ESPNowBridge5beginEv",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "bridge.udp",
+ "evidence": "_ZN9UDPBridge5beginEv",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.update.wifi",
+ "evidence": "Started: http://%s/update",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.update.lora",
+ "evidence": "image_hash MISMATCH after decode",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "cli.retry_preset",
+ "evidence": "retry.preset",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "telemetry.history",
+ "evidence": "telemetry.volt.i2c",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.cli",
+ "evidence": "OTA: status",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "web.webconfig",
+ "evidence": "start webconfig",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "logging.usb.packets",
+ "evidence": "%s: %s, len=%d (type=%d, route=%s, payload_len=%d)",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "logging.usb.control",
+ "evidence": "OK - USB logging %s (saved)",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "logging.usb.output",
+ "evidence": "OK - logging.output %s (saved)",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "bridge.udp.configuration",
+ "evidence": "udp.enabled",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "radio.station_g3.auto_slots",
+ "evidence": "meshcore.capability.station_g3.auto_slots.v1",
+ "present": true,
+ "source": "packaged application"
+ }
+ ],
+ "verified": true
+ },
+ "application_bytes": 2156504,
+ "application_slot_bytes": 6553600,
+ "dram_bss_bytes": 142088,
+ "dram_data_bytes": 23980
+ },
+ "final": {
+ "memory": {
+ "schema_version": 1,
+ "target": "Station_G3_ESP32_repeater",
+ "platform": "ESP32_PLATFORM",
+ "mcu": "esp32s3",
+ "elf_sha256": "96c94f1cafd78cbb9ef8429dbef746225ab0f32aa5b23f130fb42c88ea762fda",
+ "passed": true,
+ "available_internal_bytes": 236752,
+ "largest_internal_region_bytes": 203984,
+ "required_heap_bytes": 234636,
+ "required_contiguous_bytes": 19168,
+ "components": {
+ "core_tasks_usb_filesystems": 24576,
+ "wireless_stacks": 49152,
+ "mqtt_startup_allowance": 24576,
+ "udp_bridge_and_socket": 5120,
+ "ota_source_scratch": 8192,
+ "browser_terminal_session": 2048,
+ "neighbor_history": 18448,
+ "host_kiss_codec": 2048,
+ "radio_packet_pool": 10240,
+ "client_table": 9104,
+ "flood_filter_table": 19168,
+ "repeater_scope_storage_fallback": 18892,
+ "repeater_advert_storage_fallback": 17936,
+ "ota_generic_source_buffers": 3616,
+ "ota_encoder_workspace": 1040,
+ "display_pixels_and_driver": 2048,
+ "screen_objects_and_history": 2048,
+ "allocation_and_transient_margin": 16384
+ },
+ "display": "SH1106Display",
+ "full_companion": false,
+ "mqtt": {
+ "budget_scope": "startup minimum with runtime connection admission",
+ "startup_allowance_bytes": 24576,
+ "startup_active_slot_allowance": 2,
+ "runtime_slot_count": 6,
+ "maximum_active_slots": 5,
+ "maximum_active_slots_without_psram": 2,
+ "worker_stack_bytes": 8192,
+ "client_task_stack_bytes": 6144,
+ "client_task_priority": 5,
+ "client_rx_tx_buffer_bytes": 1792,
+ "maximum_slot_stack_buffer_allowance_bytes": 48384,
+ "cold_start_allocation_allowance_bytes": 16384,
+ "cold_start_dma_admission_free_bytes": 32768,
+ "cold_start_dma_admission_contiguous_bytes": 8192,
+ "additional_broker_cold_start_dma_admission_contiguous_bytes": 20480,
+ "restart_allocation_allowance_bytes": 8192,
+ "restart_dma_admission_free_bytes": 24576,
+ "restart_dma_admission_contiguous_bytes": 8192,
+ "additional_broker_restart_dma_admission_contiguous_bytes": 12288,
+ "dma_admission_free_bytes": 16384,
+ "dma_admission_contiguous_bytes": 4096,
+ "publish_contiguous_bytes": 4096,
+ "publish_contiguous_without_psram_bytes": 8000,
+ "publish_contiguous_with_psram_bytes": 4096,
+ "tls_psram_admission_contiguous_bytes": 16000,
+ "additional_broker_cold_start_tls_psram_admission_contiguous_bytes": 32384,
+ "additional_broker_restart_tls_psram_admission_contiguous_bytes": 24192,
+ "additional_broker_tls_psram_admission_contiguous_bytes": 16000,
+ "tls_internal_admission_free_bytes": 61440,
+ "tls_record_allocation_bytes": 17408,
+ "additional_broker_cold_start_tls_internal_admission_contiguous_bytes": 41792,
+ "additional_broker_restart_tls_internal_admission_contiguous_bytes": 33600,
+ "tls_internal_admission_contiguous_bytes": 17408,
+ "additional_broker_tls_internal_admission_contiguous_bytes": 25408,
+ "maximum_runtime_load_qualified": false,
+ "physical_validation_performed": false,
+ "limitations": "Maximum TLS/WSS/outbox workload is not covered by the startup allowance; runtime free DMA heap and fragmentation must satisfy connection admission; additional brokers reserve publication headroom beside startup allocations."
+ },
+ "regions": [
+ {
+ "start": 1070299712,
+ "end": 1070503696
+ },
+ {
+ "start": 1070530560,
+ "end": 1070563328
+ }
+ ],
+ "scope": "Linked capacity before runtime allocation; physical boot/load/soak validation remains required.",
+ "artifact_target": "Station_G3_ESP32_repeater",
+ "pio_environment": "Station_G3_ESP32_repeater_observer_mqtt",
+ "files": {
+ "Station_G3_ESP32_repeater-full-usb-wifi-ota-v1.18.0.2-psram-test-88bfaebb.bin": "d3a941a6566d66e76b9315f16f76ec7fbb7d233734dbe5c74ec2a96a3b8344b9",
+ "Station_G3_ESP32_repeater-full-usb-wifi-ota-v1.18.0.2-psram-test-88bfaebb-merged.bin": "b4219d24cac25914b6d8f4b09c31868992c310aca21a5e40d03c4148dd0ef402",
+ "Station_G3_ESP32_repeater-full-usb-wifi-ota-v1.18.0.2-psram-test-88bfaebb.capabilities.json": "8efba981bbc219c81fedc78dbf04f4f358d77cf368747c6310ef7bc0a88d6d77",
+ "Station_G3_ESP32_repeater-full-usb-wifi-ota-v1.18.0.2-psram-test-88bfaebb.meshtext-license.txt": "c37d0a453a2491514bac33ed835c1877f207390f134ae439a97b06b004e236d2"
+ }
+ },
+ "capabilities": {
+ "artifact_target": "Station_G3_ESP32_repeater",
+ "build_profile": "full",
+ "build_recipe": {
+ "schema_version": 1,
+ "sha256": "8ffd9cd9ad1dd47a035110d7108ea8a178ad4b9ea7c5552bca41f0d9c33fcef3"
+ },
+ "capabilities": [
+ "bridge.espnow",
+ "bridge.udp",
+ "profile.full",
+ "release.full.same_partition_successor",
+ "radio2.kiss.host",
+ "radio2.kiss.usb",
+ "radio2.kiss.tcp",
+ "ota.update.wifi",
+ "ota.update.lora",
+ "cli.retry_preset",
+ "telemetry.history",
+ "ota.cli",
+ "web.webconfig",
+ "logging.usb.packets",
+ "logging.usb.control",
+ "logging.usb.output",
+ "bridge.udp.configuration",
+ "radio.station_g3.auto_slots"
+ ],
+ "ota_update_evidence": "firmware fits both OTA application slots; otadata present",
+ "ota_update_methods": [
+ "wifi",
+ "lora"
+ ],
+ "ota_update_verified": true,
+ "platform": "ESP32_PLATFORM",
+ "platformio_env": "Station_G3_ESP32_repeater_observer_mqtt",
+ "reductions": [],
+ "schema_version": 2,
+ "target": "Station_G3_ESP32_repeater",
+ "third_party_notices": [
+ {
+ "component": "espp-meshtext",
+ "file": "Station_G3_ESP32_repeater-full-usb-wifi-ota-v1.18.0.2-psram-test-88bfaebb.meshtext-license.txt",
+ "license": "MIT",
+ "sha256": "c37d0a453a2491514bac33ed835c1877f207390f134ae439a97b06b004e236d2"
+ }
+ ],
+ "verification": [
+ {
+ "capability": "radio2.kiss.host",
+ "evidence": "_ZN4mesh8hostkiss5Codec4feedEh; _ZN4mesh8hostkiss5Codec11queueOutputEhPKhj; _ZN4mesh10Dispatcher22queueForeignRadioFrameEPKhjhjNS_20RadioProfileProtocolE",
+ "present": true,
+ "source": "linked ELF symbols"
+ },
+ {
+ "capability": "radio2.kiss.usb",
+ "evidence": "Error: use set kiss on|off; Error: use set kiss.transport usb|tcp; USB: send KISS FEND to start",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "radio2.kiss.tcp",
+ "evidence": "station TCP 5004",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "bridge.espnow",
+ "evidence": "_ZN12ESPNowBridge5beginEv",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "bridge.udp",
+ "evidence": "_ZN9UDPBridge5beginEv",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.update.wifi",
+ "evidence": "Started: http://%s/update",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.update.lora",
+ "evidence": "image_hash MISMATCH after decode",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "cli.retry_preset",
+ "evidence": "retry.preset",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "telemetry.history",
+ "evidence": "telemetry.volt.i2c",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "ota.cli",
+ "evidence": "OTA: status",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "web.webconfig",
+ "evidence": "start webconfig",
+ "present": true,
+ "source": "linked image"
+ },
+ {
+ "capability": "logging.usb.packets",
+ "evidence": "%s: %s, len=%d (type=%d, route=%s, payload_len=%d)",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "logging.usb.control",
+ "evidence": "OK - USB logging %s (saved)",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "logging.usb.output",
+ "evidence": "OK - logging.output %s (saved)",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "bridge.udp.configuration",
+ "evidence": "udp.enabled",
+ "present": true,
+ "source": "packaged application"
+ },
+ {
+ "capability": "radio.station_g3.auto_slots",
+ "evidence": "meshcore.capability.station_g3.auto_slots.v1",
+ "present": true,
+ "source": "packaged application"
+ }
+ ],
+ "verified": true
+ },
+ "application_bytes": 2155752,
+ "application_slot_bytes": 6553600,
+ "dram_bss_bytes": 105312,
+ "dram_data_bytes": 23980
+ }
+ },
+ "source_sha256": {
+ "src/helpers/RepeaterPsramStorage.h": "440d6c0e9ea82575ddb34ae7fe17592b86c8e37293fde7480f42a67927441cb7",
+ "src/helpers/FloodAdvertLimiter.h": "08fa87e74a8574f4784cac2a9c4327726fc38a5bfa368e5173c2958e48cfeef7",
+ "src/helpers/ESP32WsTransportFix.cpp": "7d90e2e478aae855f4546e7b9dfcbb9b3d9ad725a13979f099f596cbab89760c",
+ "examples/simple_repeater/MyMesh.h": "0b8aaa704953bd9654840287422010e33193998e7123aaa4e8dd087bec86a335",
+ "examples/simple_repeater/MyMesh.cpp": "f901dbe36021dc16b475785da63caf74889a71c075a19d4bcd1c368df8df0d77",
+ "scripts/check_firmware_ram.py": "0a4016444a95f87e0a6ac82159f8f122ab5609067e16aea525f53ece82f362b3"
+ }
+}
diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp
index 00afb61de..e99837958 100644
--- a/examples/simple_repeater/MyMesh.cpp
+++ b/examples/simple_repeater/MyMesh.cpp
@@ -3707,6 +3707,11 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
: mesh::Mesh(radio, ms, rng, rtc, *createObserverPacketManager(32), tables),
region_map(key_store), temp_map(key_store),
_cli(board, rtc, sensors, region_map, acl, &_prefs, this),
+#if MESH_REPEATER_PSRAM_TABLES
+ flood_channel_scopes(flood_channel_storage.require("channel scope tables").scopes),
+ flood_channel_direct_scopes(flood_channel_storage.require("channel scope tables").direct),
+ flood_channel_scope_requirements(flood_channel_storage.require("channel scope tables").requirements),
+#endif
telemetry(MAX_PACKET_PAYLOAD - 4),
discover_limiter(4, 120), // max 4 every 2 minutes
anon_limiter(4, 180) // max 4 every 3 minutes
diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h
index a6efa7300..6e6ee587c 100644
--- a/examples/simple_repeater/MyMesh.h
+++ b/examples/simple_repeater/MyMesh.h
@@ -9,6 +9,7 @@
#include
#include
#include
+#include
#if defined(ENABLE_OTA)
#include
#endif
@@ -558,11 +559,34 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
uint8_t flood_packet_filter_blacklist_count;
uint8_t flood_packet_filter_blacklist[FLOOD_PACKET_FILTER_BLACKLIST_MAX]
[FLOOD_PACKET_FILTER_PATH_ID_SIZE];
+#if MESH_REPEATER_PSRAM_TABLES
+ // Keep real array references: persistence, resets and rollback use sizeof
+ // these complete tables, and routing retains addresses into them.
+ struct FloodChannelStorage {
+ FloodChannelScopeEntry scopes[FLOOD_CHANNEL_SCOPE_SLOTS];
+ char direct[FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS][FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
+ FloodChannelScopeRequireEntry requirements[FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS];
+ };
+ static_assert(sizeof(FloodChannelScopeEntry) == 36
+ && sizeof(FloodChannelScopeRequireEntry) == 34
+ && sizeof(FloodChannelStorage)
+ <= FLOOD_CHANNEL_SCOPE_SLOTS * 36U
+ + FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS * 32U
+ + FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS * 34U + 2U,
+ "Update the repeater PSRAM fallback budget in check_firmware_ram.py");
+ mesh::RepeaterPsramStorage flood_channel_storage;
+ FloodChannelScopeEntry (&flood_channel_scopes)[FLOOD_CHANNEL_SCOPE_SLOTS];
+ char (&flood_channel_direct_scopes)[FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS]
+ [FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
+ FloodChannelScopeRequireEntry
+ (&flood_channel_scope_requirements)[FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS];
+#else
FloodChannelScopeEntry flood_channel_scopes[FLOOD_CHANNEL_SCOPE_SLOTS];
char flood_channel_direct_scopes[FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS]
[FLOOD_PACKET_FILTER_SCOPE_NAME_LEN];
FloodChannelScopeRequireEntry
flood_channel_scope_requirements[FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS];
+#endif
#if MESH_ENABLE_FLOOD_GROUP_MODERATION
FloodGroupModerationEntry flood_group_moderation[FLOOD_GROUP_MODERATION_SLOTS];
#endif
diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py
index 58b7746d7..8624b6e5a 100644
--- a/scripts/check_firmware_ram.py
+++ b/scripts/check_firmware_ram.py
@@ -354,6 +354,27 @@ def requirements(platform, defines, target):
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 * (304 if engine else 40) + 16
+ psram_tables_default = int(platform == "ESP32_PLATFORM" and qualified_repeater
+ and "STATION_G3_ESP32" in defines
+ and "BOARD_HAS_PSRAM" in defines)
+ if platform == "ESP32_PLATFORM" and integer(
+ defines, "MESH_REPEATER_PSRAM_TABLES", psram_tables_default):
+ # The data normally lives in PSRAM, but all capacities also need
+ # safe internal fallback. Moving it out of BSS cannot erase that
+ # requirement or relax startup admission when PSRAM is missing.
+ scopes = integer(defines, "FLOOD_CHANNEL_SCOPE_SLOTS",
+ 31 if "CONFIG_IDF_TARGET_ESP32" in defines else 255)
+ required = integer(defines, "FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS", scopes)
+ direct = integer(defines, "FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS",
+ min(scopes, integer(defines, "MAX_REGION_ENTRIES", 32)))
+ parts["repeater_scope_storage_fallback"] = (
+ scopes * 36 + required * 34 + direct * 32 + 2 + 16)
+ if ("PORTABLE_MQTT_OBSERVER" not in defines
+ and not integer(defines, "FLOOD_ADVERT_IRAM_WORD_STORAGE", 0)
+ and not integer(defines, "FLOOD_ADVERT_RTC_FAST_SLOTS", 0)
+ and not integer(defines, "FLOOD_ADVERT_RTC_SLOW_SLOTS", 0)):
+ parts["repeater_advert_storage_fallback"] = (
+ integer(defines, "FLOOD_ADVERT_SOURCE_SLOTS", 128) * 140 + 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
@@ -473,6 +494,7 @@ def requirements(platform, defines, target):
if "expanded_message_previews" in parts:
largest = max(largest, 8192 + parts["expanded_message_previews"])
for name in ("client_table", "flood_filter_table", "room_flood_rule_table", "neighbor_history",
+ "repeater_scope_storage_fallback", "repeater_advert_storage_fallback",
"solarxiao_voltage_history",
"ota_context", "screen_objects_and_history", "ota_encoder_workspace",
"ota_generic_source_buffers", "ethernet_or_external_ota_workspaces", "ota_target_name_lookup",
diff --git a/src/helpers/ESP32WsTransportFix.cpp b/src/helpers/ESP32WsTransportFix.cpp
index 37c2e2530..a4702cae4 100644
--- a/src/helpers/ESP32WsTransportFix.cpp
+++ b/src/helpers/ESP32WsTransportFix.cpp
@@ -40,7 +40,7 @@
// the fork moves to Arduino core 3.x.
//
// Scope: this stops the heap corruption and NOTHING else. It does not make an
-// oversized response fail the handshake — v4.4's read loop also exits on
+// oversized response fail the handshake; v4.4's read loop also exits on
// `header_len < WS_BUFFER_SIZE` going false, and then still accepts the
// connection if "Sec-WebSocket-Accept:" was inside those first bytes (it comes
// early, so it usually is). ws_connect() therefore returns success on a partial
@@ -67,6 +67,11 @@
#include "esp_transport.h"
#include "esp_transport_ws.h"
+#if defined(STATION_G3_ESP32) && defined(BOARD_HAS_PSRAM)
+#include "esp_heap_caps.h"
+#include "MQTTConnectionAdmission.h"
+#endif
+
#ifndef CONFIG_WS_BUFFER_SIZE
#define CONFIG_WS_BUFFER_SIZE 1024
#endif
@@ -103,13 +108,30 @@ esp_transport_handle_t __wrap_esp_transport_ws_init(esp_transport_handle_t paren
if (ws != nullptr && ws->buffer != nullptr) {
// The buffer is untouched at this point (allocated moments ago inside
// __real_esp_transport_ws_init), so a swap is safe.
- char* padded = (char*)malloc(CONFIG_WS_BUFFER_SIZE + 1);
- if (padded != nullptr) {
- free(ws->buffer);
- ws->buffer = padded;
+ const size_t bytes = CONFIG_WS_BUFFER_SIZE + 1;
+#if defined(STATION_G3_ESP32) && defined(BOARD_HAS_PSRAM)
+ // Upgrade request/response text is CPU-only. Retain this padded buffer
+ // until the SDK destroys the transport; its ordinary free accepts PSRAM.
+ char* padded = (char*)heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
+ if (padded == nullptr) {
+ const uint32_t caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA;
+ if (MQTTConnectionAdmission::canFallback(bytes,
+ heap_caps_get_free_size(caps), heap_caps_get_largest_free_block(caps))) {
+ padded = (char*)heap_caps_malloc(bytes, caps | MALLOC_CAP_8BIT);
+ }
}
- // On alloc failure keep the original buffer: same behavior as before
- // this fix, which is still strictly better than failing init here.
+#else
+ char* padded = (char*)malloc(bytes);
+#endif
+ if (padded == nullptr) {
+ // Returning the original 1024-byte buffer would reintroduce the
+ // overflow. This transport has not entered the client's list yet;
+ // destroy releases its context/buffer without destroying its parent.
+ esp_transport_destroy(t);
+ return nullptr;
+ }
+ free(ws->buffer);
+ ws->buffer = padded;
}
}
return t;
diff --git a/src/helpers/FloodAdvertLimiter.h b/src/helpers/FloodAdvertLimiter.h
index eafd65722..abe3ad1fa 100644
--- a/src/helpers/FloodAdvertLimiter.h
+++ b/src/helpers/FloodAdvertLimiter.h
@@ -4,6 +4,7 @@
#include
#include "FloodAdvertRtcPolicy.h"
#include "FloodAdvertIramPolicy.h"
+#include "RepeaterPsramStorage.h"
#ifndef FLOOD_ADVERT_SOURCE_SLOTS
#if defined(STM32_PLATFORM)
@@ -89,6 +90,14 @@ protected:
}
#else
FloodAdvertLimiter(Entry* entries, size_t capacity) : _entries(entries), _capacity(capacity) {}
+#if MESH_REPEATER_PSRAM_TABLES
+ // The derived storage owner is initialized after this base. Bind its full
+ // table before reset() or any limiter operation can inspect the entries.
+ void bindStorage(Entry* entries) {
+ if (!entries || reinterpret_cast(entries) % alignof(Entry)) abort();
+ _entries = entries;
+ }
+#endif
#endif
FloodAdvertLimiter(const FloodAdvertLimiter&) = delete;
FloodAdvertLimiter& operator=(const FloodAdvertLimiter&) = delete;
@@ -196,6 +205,19 @@ public:
public:
StaticFloodAdvertLimiter(Entry* fast, Entry* slow)
: FloodAdvertLimiter(_storage, Capacity, fast, slow) { reset(); }
+#elif MESH_REPEATER_PSRAM_TABLES
+ static_assert(sizeof(Entry) == 140,
+ "Update the advert PSRAM fallback budget in check_firmware_ram.py");
+ struct Storage { Entry entries[Capacity]; };
+ RepeaterPsramStorage _storage;
+public:
+ StaticFloodAdvertLimiter() : FloodAdvertLimiter(nullptr, Capacity) {
+ // Exhaustion must stop startup rather than disabling advert protection
+ // or reducing its source/hash history. No full internal array is retained.
+ if (!_storage.begin("advert source history")) abort();
+ bindStorage(_storage.data()->entries);
+ reset();
+ }
#else
Entry _storage[Capacity];
public:
diff --git a/src/helpers/RepeaterPsramStorage.h b/src/helpers/RepeaterPsramStorage.h
new file mode 100644
index 000000000..5993f1c88
--- /dev/null
+++ b/src/helpers/RepeaterPsramStorage.h
@@ -0,0 +1,79 @@
+#pragma once
+
+#ifndef MESH_REPEATER_PSRAM_TABLES
+ #if defined(STATION_G3_ESP32) && defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) \
+ && defined(MESH_CLIENT_REPEATER_ONLY) && MESH_CLIENT_REPEATER_ONLY
+ #define MESH_REPEATER_PSRAM_TABLES 1
+ #else
+ #define MESH_REPEATER_PSRAM_TABLES 0
+ #endif
+#endif
+
+#if MESH_REPEATER_PSRAM_TABLES != 0 && MESH_REPEATER_PSRAM_TABLES != 1
+#error "MESH_REPEATER_PSRAM_TABLES must be 0 or 1"
+#endif
+
+#if MESH_REPEATER_PSRAM_TABLES
+#if !defined(ESP32_PLATFORM) || !defined(BOARD_HAS_PSRAM)
+#error "Repeater PSRAM tables require an ESP32 target with PSRAM support"
+#endif
+
+#include
+#if !defined(CONFIG_IDF_TARGET_ESP32S3) || !CONFIG_IDF_TARGET_ESP32S3 \
+ || !defined(CONFIG_SPIRAM_BOOT_INIT) || !CONFIG_SPIRAM_BOOT_INIT \
+ || !defined(CONFIG_SPIRAM_USE_MALLOC) || !CONFIG_SPIRAM_USE_MALLOC
+#error "Repeater PSRAM tables require ESP32-S3 with boot-initialized PSRAM heaps"
+#endif
+
+#include
+#include
+#include
+#include
+#include
+
+namespace mesh {
+
+// Own only CPU data here. ISR state, locks, task stacks and DMA buffers retain
+// their existing internal storage. G3's pinned SDK initializes PSRAM-backed
+// heaps before global constructors, so tables can keep their startup lifetime.
+template class RepeaterPsramStorage {
+ static_assert(std::is_trivial::value,
+ "Repeater PSRAM tables require zero-initialized trivial data");
+ T* value_ = nullptr;
+public:
+ RepeaterPsramStorage() = default;
+ ~RepeaterPsramStorage() { if (value_) heap_caps_free(value_); }
+ RepeaterPsramStorage(const RepeaterPsramStorage&) = delete;
+ RepeaterPsramStorage& operator=(const RepeaterPsramStorage&) = delete;
+
+ bool begin(const char* label = "repeater table") {
+ if (value_) return true;
+ value_ = static_cast(heap_caps_calloc(
+ 1, sizeof(T), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
+ if (!value_) {
+ const uint32_t caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA;
+ if (MQTTConnectionAdmission::canFallback(sizeof(T),
+ heap_caps_get_free_size(caps), heap_caps_get_largest_free_block(caps))) {
+ value_ = static_cast(heap_caps_calloc(
+ 1, sizeof(T), MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
+ }
+ }
+ if (!value_) {
+ fprintf(stderr, "Cannot allocate %s (%u bytes); full capacity is required\n",
+ label, static_cast(sizeof(T)));
+ }
+ return value_ != nullptr;
+ }
+
+ T* data() { return value_; }
+ const T* data() const { return value_; }
+ T& require(const char* label) {
+ // Used only while binding the owner's references during construction.
+ // Allocation never moves after pointers escape to routing or persistence.
+ if (!begin(label)) abort();
+ return *value_;
+ }
+};
+
+} // namespace mesh
+#endif
diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py
index 3809f20c9..6578b768e 100644
--- a/test/test_firmware_ram.py
+++ b/test/test_firmware_ram.py
@@ -453,6 +453,149 @@ int main() {
sensor = ram.requirements("NRF52_PLATFORM", {}, "t114_sensor")
self.assertEqual(sensor["components"]["client_table"], 32 * 320 + 16)
+ def test_g3_psram_tables_reserve_full_internal_fallback_without_cutting_other_owners(self):
+ flags = {"STATION_G3_ESP32": 1, "BOARD_HAS_PSRAM": 1,
+ "MESH_CLIENT_REPEATER_ONLY": 1, "WITH_MQTT_BRIDGE": 1,
+ "DISPLAY_CLASS": "SH1106Display", "ADMIN_PASSWORD": "test",
+ "ENABLE_OTA": 1, "WITH_UDP_BRIDGE": 1, "WITH_RS232_BRIDGE": "Serial2"}
+ target = "Station_G3_ESP32_repeater"
+ original = ram.requirements("ESP32_PLATFORM", {
+ **flags, "MESH_REPEATER_PSRAM_TABLES": 0}, target)
+ selected = ram.requirements("ESP32_PLATFORM", flags, target)
+ scope = 255 * 36 + 255 * 34 + 32 * 32 + 2 + 16
+ advert = 128 * 140 + 16
+ self.assertEqual(selected["components"], {
+ **original["components"], "repeater_scope_storage_fallback": scope,
+ "repeater_advert_storage_fallback": advert})
+ self.assertEqual(selected["required_heap_bytes"],
+ original["required_heap_bytes"] + scope + advert)
+ self.assertEqual(selected["mqtt"], original["mqtt"])
+ self.assertEqual(selected["components"]["allocation_and_transient_margin"], 16384)
+ self.assertEqual(selected["components"]["wireless_stacks"], 49152)
+ self.assertEqual(ram.requirements("ESP32_PLATFORM", {
+ **flags, "MESH_REPEATER_PSRAM_TABLES": 1}, target), selected)
+
+ def test_g3_psram_table_defaults_follow_board_psram_platform_and_source_role_gates(self):
+ flags = {"STATION_G3_ESP32": 1, "BOARD_HAS_PSRAM": 1,
+ "MESH_CLIENT_REPEATER_ONLY": 1}
+ cases = [
+ ("ESP32_PLATFORM", {k: v for k, v in flags.items() if k != "STATION_G3_ESP32"},
+ "other_s3_repeater"),
+ ("ESP32_PLATFORM", {k: v for k, v in flags.items() if k != "BOARD_HAS_PSRAM"},
+ "Station_G3_ESP32_repeater"),
+ ("ESP32_PLATFORM", {k: v for k, v in flags.items() if k != "MESH_CLIENT_REPEATER_ONLY"},
+ "Station_G3_ESP32_repeater"),
+ ("ESP32_PLATFORM", {**flags, "MESH_CLIENT_REPEATER_ONLY": 0},
+ "Station_G3_ESP32_repeater"),
+ ("ESP32_PLATFORM", {**flags, "MESH_CLIENT_REPEATER_ONLY": 0,
+ "COMPANION_RADIO_FULL": 1},
+ "Station_G3_ESP32_companion_radio_full"),
+ ("ESP32_PLATFORM", {**flags, "MESH_CLIENT_REPEATER_ONLY": 0},
+ "Station_G3_ESP32_room_server"),
+ ]
+ for platform in ("NRF52_PLATFORM", "RP2040_PLATFORM", "STM32_PLATFORM"):
+ cases.extend((platform, {**flags, **extra}, "other_repeater")
+ for extra in ({}, {"MESH_REPEATER_PSRAM_TABLES": 1}))
+ for platform, defines, target in cases:
+ with self.subTest(platform=platform, defines=defines, target=target):
+ policy = ram.requirements(platform, defines, target)
+ self.assertNotIn("repeater_scope_storage_fallback", policy["components"])
+ self.assertNotIn("repeater_advert_storage_fallback", policy["components"])
+ self.assertEqual(policy, ram.requirements(platform, {
+ **defines, "MESH_REPEATER_PSRAM_TABLES": 0}, target))
+ # The source role, rather than a release filename, selects MyMesh.
+ renamed = ram.requirements("ESP32_PLATFORM", flags, "custom_board_forwarder")
+ self.assertIn("repeater_scope_storage_fallback", renamed["components"])
+ self.assertIn("repeater_advert_storage_fallback", renamed["components"])
+
+ def test_explicit_psram_repeater_table_opt_in_and_capacity_overrides_are_fully_budgeted(self):
+ flags = {"BOARD_HAS_PSRAM": 1, "MESH_CLIENT_REPEATER_ONLY": 1,
+ "MESH_REPEATER_PSRAM_TABLES": 1}
+ cases = (
+ ({}, 255, 255, 32, 128),
+ ({"FLOOD_CHANNEL_SCOPE_SLOTS": 5, "MAX_REGION_ENTRIES": 2}, 5, 5, 2, 128),
+ ({"FLOOD_CHANNEL_SCOPE_SLOTS": 7, "FLOOD_CHANNEL_SCOPE_REQUIRE_SLOTS": 11,
+ "FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS": 3, "FLOOD_ADVERT_SOURCE_SLOTS": 17},
+ 7, 11, 3, 17),
+ ({"FLOOD_CHANNEL_SCOPE_SLOTS": 3, "MAX_REGION_ENTRIES": 2,
+ "FLOOD_CHANNEL_DIRECT_SCOPE_SLOTS": 9}, 3, 3, 9, 128),
+ ({"MAX_REGION_ENTRIES": 64, "FLOOD_ADVERT_SOURCE_SLOTS": 200},
+ 255, 255, 64, 200),
+ )
+ for extra, scopes, required, direct, adverts in cases:
+ with self.subTest(extra=extra):
+ defines = {**flags, **extra}
+ selected = ram.requirements("ESP32_PLATFORM", defines, "other_s3_repeater")
+ original = ram.requirements("ESP32_PLATFORM", {
+ **defines, "MESH_REPEATER_PSRAM_TABLES": 0}, "other_s3_repeater")
+ scope = scopes * 36 + required * 34 + direct * 32 + 2 + 16
+ advert = adverts * 140 + 16
+ self.assertEqual(selected["components"], {
+ **original["components"], "repeater_scope_storage_fallback": scope,
+ "repeater_advert_storage_fallback": advert})
+ self.assertEqual(selected["required_heap_bytes"],
+ original["required_heap_bytes"] + scope + advert)
+
+ def test_psram_advert_budget_excludes_portable_iram_and_rtc_storage_only(self):
+ flags = {"STATION_G3_ESP32": 1, "BOARD_HAS_PSRAM": 1,
+ "MESH_CLIENT_REPEATER_ONLY": 1}
+ target = "Station_G3_ESP32_repeater"
+ original = ram.requirements("ESP32_PLATFORM", flags, target)
+ advert = original["components"]["repeater_advert_storage_fallback"]
+ for extra in ({"PORTABLE_MQTT_OBSERVER": 1}, {"PORTABLE_MQTT_OBSERVER": 0},
+ {"FLOOD_ADVERT_IRAM_WORD_STORAGE": 1},
+ {"FLOOD_ADVERT_RTC_FAST_SLOTS": 16},
+ {"FLOOD_ADVERT_RTC_SLOW_SLOTS": 50},
+ {"FLOOD_ADVERT_RTC_FAST_SLOTS": 50, "FLOOD_ADVERT_RTC_SLOW_SLOTS": 50}):
+ with self.subTest(extra=extra):
+ policy = ram.requirements("ESP32_PLATFORM", {**flags, **extra}, target)
+ self.assertEqual(policy["components"], {
+ k: v for k, v in original["components"].items()
+ if k != "repeater_advert_storage_fallback"})
+ self.assertEqual(policy["required_heap_bytes"],
+ original["required_heap_bytes"] - advert)
+ disabled_backends = {"FLOOD_ADVERT_IRAM_WORD_STORAGE": 0,
+ "FLOOD_ADVERT_RTC_FAST_SLOTS": 0, "FLOOD_ADVERT_RTC_SLOW_SLOTS": 0}
+ self.assertEqual(ram.requirements("ESP32_PLATFORM", {
+ **flags, **disabled_backends}, target), original)
+
+ def test_each_psram_table_fallback_can_set_the_contiguous_allocation_bound(self):
+ flags = {"STATION_G3_ESP32": 1, "BOARD_HAS_PSRAM": 1,
+ "MESH_CLIENT_REPEATER_ONLY": 1, "MAX_CLIENTS": 1,
+ "FLOOD_PACKET_FILTER_SLOTS": 1, "MAX_RECENT_REPEATERS": 0}
+ for extra, name in (({}, "repeater_scope_storage_fallback"),
+ ({"FLOOD_ADVERT_SOURCE_SLOTS": 200},
+ "repeater_advert_storage_fallback")):
+ with self.subTest(extra=extra):
+ policy = ram.requirements("ESP32_PLATFORM", {**flags, **extra}, "g3_repeater")
+ self.assertEqual(policy["required_contiguous_bytes"], policy["components"][name])
+ self.assertGreater(policy["required_contiguous_bytes"], 16384)
+
+ def test_g3_psram_fallback_elf_gate_rejects_old_budget_and_enforces_exact_total(self):
+ flags = {"STATION_G3_ESP32": 1, "BOARD_HAS_PSRAM": 1,
+ "MESH_CLIENT_REPEATER_ONLY": 1, "WITH_MQTT_BRIDGE": 1}
+ target = "Station_G3_ESP32_repeater"
+ previous = {**flags, "MESH_REPEATER_PSRAM_TABLES": 0}
+ old = ram.requirements("ESP32_PLATFORM", previous, target)["required_heap_bytes"]
+ required = ram.requirements("ESP32_PLATFORM", flags, target)["required_heap_bytes"]
+ with tempfile.TemporaryDirectory() as temporary:
+ path = Path(temporary) / "firmware.elf"
+ report = Path(temporary) / "firmware.memory.json"
+ for capacity, accepted in ((old, False), (required - 1, False), (required, True)):
+ with self.subTest(capacity=capacity), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
+ esp_fixture(path, modern=True, internal_length=capacity + 0x4000)
+ self.assertEqual(ram.check_firmware(path, "ESP32_PLATFORM", "esp32s3",
+ previous, target), 0)
+ status = ram.check_firmware(path, "ESP32_PLATFORM", "esp32s3",
+ flags, target, report)
+ result = json.loads(report.read_text())
+ self.assertEqual(status == 0, accepted)
+ self.assertEqual(result["passed"], accepted)
+ self.assertEqual(result["available_internal_bytes"], capacity)
+ self.assertEqual(result["required_heap_bytes"], required)
+ self.assertEqual(result["components"]["repeater_scope_storage_fallback"], 18892)
+ self.assertEqual(result["components"]["repeater_advert_storage_fallback"], 17936)
+
def test_browser_terminal_reserves_internal_session_and_psram_aware_scrollback(self):
defines = {"ENABLE_USB_INTERFACE": 1, "WIFI_SSID": "", "DISPLAY_CLASS": "SSD1306Display"}
base = ram.requirements("ESP32_PLATFORM", {**defines, "WEBCONFIG_DISABLED": 1}, "v4_companion")
diff --git a/test/test_flood_advert_psram.py b/test/test_flood_advert_psram.py
new file mode 100644
index 000000000..50c34969a
--- /dev/null
+++ b/test/test_flood_advert_psram.py
@@ -0,0 +1,209 @@
+"""Keep every advert-history decision while moving its complete table to PSRAM."""
+from pathlib import Path
+import os
+import resource
+import signal
+import subprocess
+import tempfile
+import unittest
+
+from test_flood_advert_rtc import HARNESS
+
+ROOT = Path(__file__).resolve().parents[1]
+
+HEAP = r'''
+#pragma once
+#include
+#include
+#include
+#include