From d11bf3615df8b5c3c662b8a1900c54f8b82b57a1 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 10 Oct 2026 15:46:53 -0700 Subject: [PATCH] Count runtime WiFi memory in every ESP32 MQTT profile --- docs/research/firmware_memory_budget.md | 4 +++ scripts/check_firmware_ram.py | 5 ++- test/test_firmware_ram.py | 42 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/research/firmware_memory_budget.md b/docs/research/firmware_memory_budget.md index e430de07..4c6fa635 100644 --- a/docs/research/firmware_memory_budget.md +++ b/docs/research/firmware_memory_budget.md @@ -33,6 +33,10 @@ Classic ESP32 additionally retains its existing 8 KiB **static DRAM** check. The policy adds allowances for task stacks, radio packet pools, screen objects and pixel buffers, filesystem/sensor allocations, enabled wireless stacks, MQTT connections, OTA scratch and transient allocations. ESP32 images with +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 the source-bounded bridge object and allocation metadata, plus an unchanged 4 KiB for the UART driver and buffers. This diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index b2a5cead..978152f0 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -142,7 +142,10 @@ def requirements(platform, defines, target): parts["uart_bridge_and_driver"] = 2560 elif platform == "ESP32_PLATFORM": parts["core_tasks_usb_filesystems"] = 24576 - wifi = 49152 if "WIFI_SSID" in defines or "WIFI_OTA_SEEDER" in defines else 0 + # MQTT joins with saved runtime credentials even without a compiled SSID + # or OTA seeder. Count its mandatory WiFi stack once with other WiFi uses. + wifi = 49152 if any(name in defines for name in ( + "WIFI_SSID", "WIFI_OTA_SEEDER", "WITH_MQTT_BRIDGE")) else 0 ble = 32768 if "BLE_PIN_CODE" in defines else 0 parts["wireless_stacks"] = max(wifi, ble) if "COMPANION_EXCLUSIVE_WIFI_BLE" in defines else wifi + ble if "WITH_MQTT_BRIDGE" in defines: diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 57fb4a4e..04cf1632 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -233,6 +233,48 @@ class FirmwareRamTest(unittest.TestCase): ordinary = ram.requirements("ESP32_PLATFORM", {"BOARD_HAS_PSRAM": 1}, "v4_companion") self.assertNotIn("mqtt", ordinary) + def test_mqtt_without_build_time_wifi_credentials_reserves_wifi_stack_exactly_once(self): + # MQTT can use persisted station credentials without either compile-time + # WiFi option. Isolate the wireless budget from optional browser sessions. + base = {"WEBCONFIG_DISABLED": 1} + for target in ("Tbeam_SX1262_repeater_observer_mqtt", "v4_companion"): + ordinary = ram.requirements("ESP32_PLATFORM", base, target) + self.assertEqual(ordinary["components"]["wireless_stacks"], 0) + mqtt = ram.requirements("ESP32_PLATFORM", {**base, "WITH_MQTT_BRIDGE": 1}, target) + self.assertEqual(mqtt["components"]["wireless_stacks"], 49152) + self.assertEqual(mqtt["required_heap_bytes"] - ordinary["required_heap_bytes"], + 49152 + 24576) + for extra in ({"WIFI_SSID": ""}, {"WIFI_OTA_SEEDER": 1}, + {"WIFI_SSID": "saved-at-runtime", "WIFI_OTA_SEEDER": 1}): + with self.subTest(target=target, extra=extra): + combined = ram.requirements("ESP32_PLATFORM", { + **base, "WITH_MQTT_BRIDGE": 1, **extra, + }, target) + self.assertEqual(combined, mqtt) + + def test_mqtt_only_elf_gate_rejects_the_previously_omitted_wifi_allowance(self): + defines = {"WITH_MQTT_BRIDGE": 1, "WEBCONFIG_DISABLED": 1} + target = "Tbeam_SX1262_repeater_observer_mqtt" + policy = ram.requirements("ESP32_PLATFORM", defines, target) + self.assertEqual(policy["components"]["wireless_stacks"], 49152) + required = policy["required_heap_bytes"] + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "firmware.elf" + report = Path(temporary) / "firmware.memory.json" + # The old WiFi-omitted policy accepted the first linked capacity. + # The corrected gate must reject it and enforce the exact new bound. + for capacity, expected in ((required - 49152, 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) + status = ram.check_firmware(path, "ESP32_PLATFORM", "esp32s3", + defines, target, report) + result = json.loads(report.read_text()) + self.assertEqual(status == 0, expected) + self.assertEqual(result["passed"], expected) + self.assertEqual(result["available_internal_bytes"], capacity) + self.assertEqual(result["required_heap_bytes"], required) + def test_mqtt_worker_stack_growth_raises_gate_and_contiguous_requirement(self): flags = {"WITH_MQTT_BRIDGE": 1, "BOARD_HAS_PSRAM": 1} base = ram.requirements("ESP32_PLATFORM", flags, "v4_companion")