From b8f8a1fae1ed3d139605c85ae2df9174a900100a Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 18 Oct 2026 16:32:49 -0700 Subject: [PATCH] Account for ST7789 framebuffer and scanline heap in firmware gates --- .../heltec_t114_display_detection.md | 6 + scripts/check_firmware_ram.py | 23 ++- test/test_firmware_ram.py | 135 ++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) diff --git a/docs/test-results/heltec_t114_display_detection.md b/docs/test-results/heltec_t114_display_detection.md index 94a9755ff..9d946c58f 100644 --- a/docs/test-results/heltec_t114_display_detection.md +++ b/docs/test-results/heltec_t114_display_detection.md @@ -35,6 +35,11 @@ does not require a single guessed factory ID. - A detected panel retains normal rendering, sleep, wake, and display settings. - Detection is specific to the T114 automatic-display recipe. Other boards using the shared ST7789 driver retain their existing behavior. +- The RAM gate budgets a responding screen even when a particular board boots + headless. The T114 display needs two 4,080-byte buffers and one temporary + 480-byte RGB565 row: 8,640 bytes of peak heap allocation, in addition to the + normal allocator and transient margin. The same model follows the shared + driver's T190 and M9 panel sizes and its single-buffer configuration. - Picker consolidation requires evidence from the exact published application. Historical releases retain their original screen behavior and update IDs. @@ -46,6 +51,7 @@ does not require a single guessed factory ID. | Resolved recipe and compatibility-alias checks | PASS, 5 methods; actual PlatformIO configuration also confirms all 5 legacy aliases match their canonical targets | | Companion headless buttons and related navigation, reader, pairing, startup and deadline suites | PASS, 55 methods, including native headless fault regression and 4 input/reader configurations | | Picker and release-selection regressions | PASS, 78 Python methods and 3 Node suites; old assets, scoped previews, newest-role defaults and unavailable legacy choices covered | +| Display allocation accounting and firmware RAM gates | PASS, 46 methods, including production allocation tracking across 3 panel sizes and buffer configurations | | T114 firmware and package checks | Pending | | Hosted documentation links | PASS, 7 methods | | Physical T114 with and without a screen | Not performed | diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index bcd6e130e..d9ea805ba 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -30,7 +30,7 @@ DISPLAY_HEAP = { "SSD1306Display": 4096, "SH1106Display": 4096, "U8g2Display": 4096, "SH1107Display": 4096, # These drivers render directly or embed their pixel storage in globals. - "ST7789Display": 0, "ST7789LCDDisplay": 0, "NV3001BDisplay": 0, + "ST7789LCDDisplay": 0, "NV3001BDisplay": 0, "GxEPDDisplay": 0, "E213Display": 8192, "E290Display": 8192, } @@ -125,6 +125,25 @@ def wio_tracker_l2_display_heap(defines): return row_bytes * height + 1024 +def st7789_display_heap(defines): + # Match ST7789Display's constructor branches and ST7789Spi's byte-page + # geometry, including the partial last page on the T114 and T190 panels. + if "HELTEC_VISION_MASTER_T190" in defines: + width, height = 320, 170 + elif "THINKNODE_M9" in defines: + width, height = 320, 240 + else: + width, height = 240, 135 + buffers = (1 if "OLEDDISPLAY_REDUCE_MEMORY" in defines + and "OLEDDISPLAY_DOUBLE_BUFFER" not in defines else 2) + # OLEDDisplay mallocs the persistent front/back buffers. ST7789Spi also + # mallocs one RGB565 scanline, freed before allocating the next line. + # The global driver is already linked; allocator overhead remains covered + # by the separate allocation/transient margin. Auto-detection must budget + # a present panel even when a particular board starts without one. + return width * ((height + 7) // 8) * buffers + width * 2 + + def requirements(platform, defines, target): if platform not in SUPPORTED: raise ValueError(f"no memory policy for {platform}") @@ -143,6 +162,8 @@ def requirements(platform, defines, target): if platform != "ESP32_PLATFORM": raise ValueError("Wio Tracker L2 display budget requires ESP32") display_heap = wio_tracker_l2_display_heap(defines) + elif display == "ST7789Display": + display_heap = st7789_display_heap(defines) elif display == "ST7735Display": depth = integer(defines, "ST7735_BUFFER_COLOR_DEPTH", 16) if depth not in (8, 16): diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index a0f43f20d..989235a53 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -18,6 +18,7 @@ import check_firmware_ram as ram from firmware_elf import FirmwareElf import firmware_memory_manifest as proof from audit_esp32_image_ram import EspImage +from test_replay_reset_integration import extract_braced def elf32(path, symbols, data=b"", address=0x3F400000): @@ -68,6 +69,140 @@ def esp_fixture(path, modern=False, fragmented=False, internal_length=0x10000): class FirmwareRamTest(unittest.TestCase): + def test_st7789_budget_matches_production_allocation_peak_and_reuses_buffers(self): + ui = ROOT / "src/helpers/ui" + spi = (ui / "ST7789Spi.h").read_text() + oled = (ui / "OLEDDisplay.cpp").read_text() + header = (ui / "ST7789Display.h").read_text() + start = header.index("#if defined(HELTEC_VISION_MASTER_T190)") + constructors = header[start:header.index("#endif", start) + len("#endif")] + buffering = (ui / "OLEDDisplay.h").read_text() + start = buffering.index("#ifndef OLEDDISPLAY_REDUCE_MEMORY") + buffering = buffering[start:buffering.index("#endif", start) + len("#endif")] + fixture = r''' +#include +#include +#include +#include +#include +#include +#include +using std::min; using std::max; +size_t live=0, peak=0, largest=0, calls=0; +std::vector> allocations; +void* track_malloc(size_t size) { + void* p=std::malloc(size); assert(p); allocations.push_back({p,size}); + live+=size; peak=max(peak,live); largest=max(largest,size); ++calls; return p; +} +void track_free(void* p) { + auto at=std::find_if(allocations.begin(),allocations.end(),[&](auto& a){return a.first==p;}); + assert(at!=allocations.end()); live-=at->second; allocations.erase(at); std::free(p); +} +#define malloc track_malloc +#define free track_free +#define rtos_malloc track_malloc +#define rtos_free track_free +#define DEBUG_OLEDDISPLAY(...) +#define LOW 0 +#define HIGH 1 +void yield() {} +enum OLEDDISPLAY_GEOMETRY { GEOMETRY_128_128,GEOMETRY_128_64,GEOMETRY_128_32, + GEOMETRY_64_48,GEOMETRY_64_32,GEOMETRY_RAWMODE }; +@BUFFERING@ +struct Bus { + unsigned rows=0; + template void beginTransaction(T) {} + void endTransaction() {} + void transfer(void*,void*,int) { ++rows; } +}; +struct Panel { + uint16_t displayWidth=0,displayHeight=0,displayBufferSize=0,_buffheight=0,_RGB=0xffff; + OLEDDISPLAY_GEOMETRY geometry=GEOMETRY_RAWMODE; + unsigned logBufferSize=0,logBufferFilled=0,logBufferLine=0,logBufferMaxLines=0; + uint8_t* buffer=nullptr; uint8_t* buffer_back=nullptr; uint8_t* logBuffer=nullptr; + unsigned BufferOffset=0,_spiSettings=0; + Bus bus; Bus* _spi=&bus; + Panel(int*,uint8_t,uint8_t,uint8_t,OLEDDISPLAY_GEOMETRY g,uint16_t w,uint16_t h, + int=-1,int=-1,int=-1) { setGeometry(g,w,h); } + bool connect() { _buffheight=(displayHeight+7)/8; return true; } + void set_CS(int) {} + void setAddrWindow(uint16_t,uint16_t,uint16_t,uint16_t) {} +@GEOMETRY@ +@ALLOCATE@ +@FLUSH@ +}; +int SPI=0,SPI1=0; +constexpr uint8_t PIN_TFT_RST=2,PIN_TFT_DC=12,PIN_TFT_CS=11,PIN_TFT_SDA=41,PIN_TFT_SCL=40; +constexpr uint8_t ST7789_RESET=14,ST7789_RS=15,ST7789_CS=16,ST7789_SDA=13,ST7789_MISO=14,ST7789_SCK=12; +struct DisplayDriver { DisplayDriver(int,int) {} }; +struct ST7789Display:DisplayDriver { + Panel display; bool _isOn=false; +@CONSTRUCTORS@ +}; +int main() { + ST7789Display d; Panel& p=d.display; + assert(p.allocateBuffer()); + std::fill(p.buffer,p.buffer+p.displayBufferSize,0xff); +#ifdef OLEDDISPLAY_DOUBLE_BUFFER + std::fill(p.buffer_back,p.buffer_back+p.displayBufferSize,0); +#endif + p.display(); assert(p.bus.rows==p.displayHeight); + const auto count=calls, persistent=live; + assert(p.allocateBuffer()); assert(calls==count && live==persistent); + std::printf("%zu %zu %zu\n",live,peak,largest); + track_free(p.buffer); +#ifdef OLEDDISPLAY_DOUBLE_BUFFER + track_free(p.buffer_back); +#endif + assert(live==0); +} +''' + for marker, value in { + "@BUFFERING@": buffering, "@CONSTRUCTORS@": constructors, + "@GEOMETRY@": extract_braced(spi, "void setGeometry("), + "@ALLOCATE@": extract_braced(oled, "bool OLEDDisplay::allocateBuffer(").replace( + "OLEDDisplay::", ""), + "@FLUSH@": extract_braced(spi, "void display(void)")}.items(): + fixture = fixture.replace(marker, value) + with tempfile.TemporaryDirectory(prefix="st7789-ram-") as temporary: + for board in ({"HELTEC_T114": 1, "T114_DISPLAY_AUTODETECT": 1}, + {"HELTEC_VISION_MASTER_T190": 1}, {"THINKNODE_M9": 1}): + for buffers in ({}, {"OLEDDISPLAY_REDUCE_MEMORY": 0}, + {"OLEDDISPLAY_REDUCE_MEMORY": 1, "OLEDDISPLAY_DOUBLE_BUFFER": 0}): + defines = {**board, **buffers, "DISPLAY_CLASS": "ST7789Display"} + with self.subTest(defines=defines): + binary = Path(temporary) / "allocations" + compiled = subprocess.run([ + "c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", "-x", "c++", "-", + *[f"-D{k}={v}" for k,v in {**board, **buffers}.items()], + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + "-o", str(binary)], input=fixture, text=True, capture_output=True) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + checked = subprocess.run([str(binary)], text=True, capture_output=True, timeout=15) + self.assertEqual(checked.returncode, 0, checked.stderr) + persistent, peak, largest = map(int, checked.stdout.split()) + policy = ram.requirements("NRF52_PLATFORM", defines, "display_companion") + self.assertEqual(policy["components"]["display_pixels_and_driver"], peak) + self.assertGreater(peak, persistent) # Scanline is heap, freed after each transfer. + self.assertGreaterEqual(policy["required_contiguous_bytes"], largest) + + def test_t114_present_panel_is_budgeted_for_auto_and_legacy_with_exact_admission(self): + flags = {"DISPLAY_CLASS": "ST7789Display", "HELTEC_T114": 1, + "T114_DISPLAY_AUTODETECT": 1, "COMPANION_RADIO_FULL": 1, "BLE_PIN_CODE": 123456} + target = "Heltec_t114_companion_radio_full" + policy = ram.requirements("NRF52_PLATFORM", flags, target) + self.assertEqual(policy["components"]["display_pixels_and_driver"], 8640) + self.assertEqual(policy["components"]["allocation_and_transient_margin"], 4096) + legacy = {key:value for key,value in flags.items() if key != "T114_DISPLAY_AUTODETECT"} + self.assertEqual(ram.requirements("NRF52_PLATFORM", legacy, target), policy) + required = policy["required_heap_bytes"] + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "firmware.elf" + for capacity, accepted in ((required-8640, False), (required-1, False), (required, True)): + with self.subTest(capacity=capacity), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + elf32(path, {"__HeapBase": (0x2003F800-capacity,0), "__HeapLimit": (0x2003F800,0)}) + self.assertEqual(ram.check_firmware(path,"NRF52_PLATFORM","nrf52840",flags,target)==0, accepted) + def test_device_encoder_budget_matches_storage_and_role_gates(self): matrix = [ ("ESP32_PLATFORM", {"ENABLE_OTA": 1, "MESHCORE_OTA_DEVICE_DEFLATE": 1}, "v4_repeater", True),