From ec4aba8a1c5dc3e9419a7a9a57ab92a05eef248c Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 26 Oct 2026 17:53:39 -0700 Subject: [PATCH] Reserve Full host KISS codec RAM without reducing existing budgets --- docs/research/firmware_memory_budget.md | 10 ++++ scripts/check_firmware_ram.py | 12 +++- src/helpers/HostKissCodec.h | 5 ++ test/test_firmware_ram.py | 15 +++-- test/test_host_kiss_infrastructure.py | 7 ++- test/test_host_kiss_ram.py | 79 +++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 test/test_host_kiss_ram.py diff --git a/docs/research/firmware_memory_budget.md b/docs/research/firmware_memory_budget.md index 48ae45fb8..5c63cc821 100644 --- a/docs/research/firmware_memory_budget.md +++ b/docs/research/firmware_memory_budget.md @@ -77,6 +77,16 @@ 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. +Enabled Full host KISS images reserve a separate 2,048-byte +`host_kiss_codec` allocation on ESP32 and nRF52, including while KISS is saved +off. Turning the service on allocates its two host-TX and two encoded-output +frames plus parser state. The source assertion bounds the complete Codec and +allocator headroom to this allowance. WiFi/TCP retains its existing network +allowance; the independent transient margin and all normal-role capacities +remain additive. Explicit feature-off and ESP-NOW-only images omit this +allocation. A passing report proves linked capacity, not physical session or +maximum network-load qualification. + ### MQTT startup minimum and runtime qualification The `mqtt_startup_allowance` component keeps a 24,576-byte internal startup diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index 8bc13aca8..092cb61fd 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -314,6 +314,16 @@ def requirements(platform, defines, target): parts["neighbor_history"] = count * 9 + (16 if count else 0) else: parts["core_filesystems_sensors"] = 8192 + if (platform in {"ESP32_PLATFORM", "NRF52_PLATFORM"} + and not integer(defines, "MESH_PRIMARY_ESPNOW", 0) + and integer(defines, "MESH_ENABLE_HOST_KISS", + integer(defines, "COMPANION_RADIO_FULL", 0))): + # Full raw KISS is lazy but can coexist with every normal-role owner. + # Its two RX/output frames, two host TX frames and framing state live + # in one Codec allocation; its source assertion retains allocator + # headroom within 2 KiB. Socket/WiFi objects retain their existing + # network allowance. This never replaces the transient margin. + parts["host_kiss_codec"] = 2048 # Packet bytes plus all three queue tables and allocation overhead. parts["radio_packet_pool"] = 5120 if companion else 10240 # These tables moved out of .bss, so linker heap bounds now include their @@ -448,7 +458,7 @@ def requirements(platform, defines, target): "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", - "ethernet_companion_worker"): + "ethernet_companion_worker", "host_kiss_codec"): largest = max(largest, parts.get(name, 0)) if "room_browser_mailbox" in parts: largest = max(largest, 8192, parts["room_browser_mailbox"]) diff --git a/src/helpers/HostKissCodec.h b/src/helpers/HostKissCodec.h index 732394cc5..e39f93e73 100644 --- a/src/helpers/HostKissCodec.h +++ b/src/helpers/HostKissCodec.h @@ -146,4 +146,9 @@ class Codec { uint32_t droppedFrames() const { return dropped_; } }; +// Lazy Codec allocation is included in check_firmware_ram.py on every +// enabled Full image. Retain allocator metadata room if queues grow later. +static_assert(sizeof(Codec) + 32 <= 2048, + "Update the host KISS Codec runtime RAM allowance"); + }} // namespace mesh::hostkiss diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index cc9674fcd..b856edaa0 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -1161,12 +1161,15 @@ int main() { "BLE_PIN_CODE": 123456, "UI_SMALL_MESSAGE_FONT": 0} with tempfile.TemporaryDirectory() as temp, contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): path = Path(temp) / "firmware.elf" - for available, accepted in ((54724, False), (73727, False), (73728, True), (74060, True)): - with self.subTest(available=available): - elf32(path, {"__HeapBase": (0x2003F800 - available, 0), "__HeapLimit": (0x2003F800, 0)}) - result = ram.check_firmware(path, "NRF52_PLATFORM", "nrf52840", flags, - "Heltec_t096_companion_radio_full_femon") - self.assertEqual(result == 0, accepted) + for host_kiss, boundary in ((0, 73728), (1, 74530)): + selected = {**flags, "MESH_ENABLE_HOST_KISS": host_kiss} + for available, accepted in ((54724, False), (boundary - 1, False), + (boundary, True), (boundary + 332, True)): + with self.subTest(host_kiss=host_kiss, available=available): + elf32(path, {"__HeapBase": (0x2003F800 - available, 0), "__HeapLimit": (0x2003F800, 0)}) + result = ram.check_firmware(path, "NRF52_PLATFORM", "nrf52840", selected, + "Heltec_t096_companion_radio_full_femon") + self.assertEqual(result == 0, accepted) def test_arm_heap_excludes_stack_softdevice_and_mota_arena(self): with tempfile.TemporaryDirectory() as temp: diff --git a/test/test_host_kiss_infrastructure.py b/test/test_host_kiss_infrastructure.py index 68552433a..84c2eaa59 100644 --- a/test/test_host_kiss_infrastructure.py +++ b/test/test_host_kiss_infrastructure.py @@ -205,10 +205,13 @@ apply_policy() { "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) for enabled, cooperative, lose_owner in ((0, 0, False), (0, 1, False), (1, 0, False), (1, 1, False), - (1, 1, True)): + (1, 1, True), (1, 1, "late")): with self.subTest(enabled=enabled, cooperative=cooperative, lose_owner=lose_owner): candidate = pump - if lose_owner: + if lose_owner == "late": + candidate = candidate.replace(" if (mesh::hostkiss::usbInputReserved(console.peek())) break;\n", "") + self.assertNotEqual(candidate, pump) + elif lose_owner: candidate = candidate.replace("if (mesh::hostkiss::serviceUsb())", "if (false && mesh::hostkiss::serviceUsb())") self.assertNotEqual(candidate, pump) diff --git a/test/test_host_kiss_ram.py b/test/test_host_kiss_ram.py new file mode 100644 index 000000000..41afefd2f --- /dev/null +++ b/test/test_host_kiss_ram.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Bind the optional KISS heap allowance to the real codec and ELF gate.""" +import contextlib +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/"scripts")) +import check_firmware_ram as ram +from test_firmware_ram import elf32,esp_fixture + + +class HostKissRamTest(unittest.TestCase): + def test_enabled_codec_adds_two_kib_without_lowering_other_reserves(self): + for platform in ("ESP32_PLATFORM","NRF52_PLATFORM"): + for target in ("board_companion_radio_full","board_repeater","board_room_server","board_sensor"): + with self.subTest(platform=platform,target=target): + flags={"BLE_PIN_CODE":123456} + off=ram.requirements(platform,{**flags,"MESH_ENABLE_HOST_KISS":0},target) + on=ram.requirements(platform,{**flags,"MESH_ENABLE_HOST_KISS":1},target) + self.assertEqual(on["components"]["host_kiss_codec"],2048) + self.assertEqual(on["required_heap_bytes"],off["required_heap_bytes"]+2048) + self.assertEqual({key:value for key,value in on["components"].items() + if key!="host_kiss_codec"},off["components"]) + self.assertEqual(on["required_contiguous_bytes"],off["required_contiguous_bytes"]) + default=ram.requirements(platform,{**flags,"COMPANION_RADIO_FULL":1},target) + self.assertEqual(default["components"],on["components"]) + for extra in ({"COMPANION_RADIO_FULL":0}, + {"COMPANION_RADIO_FULL":1,"MESH_ENABLE_HOST_KISS":0}, + {"MESH_ENABLE_HOST_KISS":1,"MESH_PRIMARY_ESPNOW":1}): + self.assertEqual(ram.requirements(platform,{**flags,**extra},target)["components"],off["components"]) + for platform in ("STM32_PLATFORM","RP2040_PLATFORM"): + self.assertEqual(ram.requirements(platform,{"MESH_ENABLE_HOST_KISS":1},"board_companion_radio_ble"), + ram.requirements(platform,{},"board_companion_radio_ble")) + + def test_real_elf_rejects_unbudgeted_codec_and_honors_exact_new_boundary(self): + for platform in ("ESP32_PLATFORM","NRF52_PLATFORM"): + with self.subTest(platform=platform),tempfile.TemporaryDirectory() as folder: + folder=Path(folder);path=folder/"firmware.elf";report=folder/"memory.json" + target="board_companion_radio_full" + flags={"COMPANION_RADIO_FULL":1,"MESH_ENABLE_HOST_KISS":1,"BLE_PIN_CODE":123456} + required=ram.requirements(platform,flags,target)["required_heap_bytes"] + old=ram.requirements(platform,{**flags,"MESH_ENABLE_HOST_KISS":0},target)["required_heap_bytes"] + self.assertEqual(required,old+2048) + for available,accepted in ((old,False),(required-1,False),(required,True)): + with self.subTest(available=available),contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()): + if platform=="ESP32_PLATFORM": + esp_fixture(path,internal_length=available-0x10000+0x4000) + else: + elf32(path,{"__HeapBase":(0x20010000,0),"__HeapLimit":(0x20010000+available,0)}) + status=ram.check_firmware(path,platform,"esp32" if platform=="ESP32_PLATFORM" else "nrf52840",flags,target,report) + actual=json.loads(report.read_text()) + self.assertEqual(status==0,accepted) + self.assertEqual(actual["required_heap_bytes"],required) + self.assertEqual(actual["components"]["host_kiss_codec"],2048) + + def test_actual_codec_size_is_source_bounded_and_queue_growth_fails(self): + source=(ROOT/"src/helpers/HostKissCodec.h").read_text() + with tempfile.TemporaryDirectory() as folder: + folder=Path(folder);header=folder/"Codec.h";main=folder/"main.cpp";binary=folder/"test" + main.write_text('#include "Codec.h"\n#include \nint main(){printf("%zu\\n",sizeof(mesh::hostkiss::Codec));}\n') + for changed,accepted in ((source,True),(source.replace("QueueDepth = 2","QueueDepth = 3"),False)): + header.write_text(changed) + result=subprocess.run(["g++","-std=c++17",str(main),"-o",str(binary)],capture_output=True,text=True) + self.assertEqual(result.returncode==0,accepted,result.stdout+result.stderr) + if accepted: + size=int(subprocess.check_output([str(binary)])) + self.assertEqual(size,1828) + self.assertLessEqual(size+32,2048) + else: + self.assertIn("runtime RAM allowance",result.stderr) + + +if __name__=="__main__":unittest.main()