From 4805b5cdbb221bf585c161dd1c39d9a09d704f08 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 17 Oct 2026 11:22:15 -0700 Subject: [PATCH] Fix T096 Companion runtime RAM with RGB332 framebuffer --- .github/workflows/run-unit-tests.yml | 6 + docs/research/firmware_memory_budget.md | 26 ++- scripts/check_firmware_ram.py | 12 +- src/helpers/ui/ST7735Display.cpp | 36 +++ test/test_firmware_ram.py | 43 ++++ test/test_nrf52_ble_startup.py | 20 ++ test/test_st7735_framebuffer.py | 294 ++++++++++++++++++++++++ variants/heltec_t096/platformio.ini | 3 + 8 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 test/test_st7735_framebuffer.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 9b7ecd741..9d4a81292 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -34,6 +34,11 @@ jobs: --firmware-version v1.17.1.10-halo-keymind-cascade-dev --radio-preset usa-cascade-fixed --profile cascade --require-ota + - name: Compile both legacy T096 BLE FEM settings with their RAM gates + # Sequential environments in one process; do not replace these with + # Full overlays, which have different OTA and runtime allocations. + run: pio run -e Heltec_t096_companion_radio_ble_femoff -e Heltec_t096_companion_radio_ble_femon + nrf52-sensor-smoke: runs-on: ubuntu-latest steps: @@ -750,6 +755,7 @@ jobs: python3 -B test/test_local_release_full_selection.py python3 -B test/test_local_release_capacity_archive.py python3 -B test/test_t096_full_memory.py + python3 -B test/test_st7735_framebuffer.py -v python3 -B test/test_nrf52_ble_startup.py python3 -B test/test_nrf52_ble_backpressure.py python3 -B test/test_nrf52_ble_rx_queue.py -v diff --git a/docs/research/firmware_memory_budget.md b/docs/research/firmware_memory_budget.md index 8cdc1da35..904afd8cf 100644 --- a/docs/research/firmware_memory_budget.md +++ b/docs/research/firmware_memory_budget.md @@ -50,7 +50,25 @@ current startup policy; these figures describe historical linked checks. Its ordinary release retains separate UART/ESP-NOW and MQTT/ESP-NOW Full images; routing capacities remain unchanged. A 160x80 ST7735 -framebuffer needs 25,602 bytes. ESP32 SSD1306 uses a source-bounded 2 KiB +RGB565 framebuffer needs 25,602 bytes. T096 recipes instead select +`ST7735_BUFFER_COLOR_DEPTH=8`: the RGB332 canvas needs 12,801 bytes, plus +a source-bounded 1 KiB allowance for the complete sprite object and allocator +metadata. Each frame expands one 160-pixel scanline into a 320-byte stack buffer +and sends the same RGB565 wire format. The current primary UI colors remain +exact; resolution, fonts, all 32 message previews, 350 contacts, 40 channels and +the 256-frame offline queue are unchanged. Other ST7735 recipes retain the +16-bit default. A future sprite-object increase beyond the allowance fails a +C++ assertion rather than silently reducing available heap. The existing loop +stack and transient margin remain additive. + +The ordinary T096 BLE FEM-on/off policy includes its receiving OTA source +scratch and full-length previews: the reported 81,474-byte requirement becomes +69,697 bytes with the smaller canvas and explicit sprite-object allowance. +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. @@ -401,6 +419,7 @@ Run PlatformIO commands sequentially in this checkout: ```sh python3 -B test/test_firmware_ram.py python3 -B test/test_t096_full_memory.py +python3 -B test/test_st7735_framebuffer.py python3 -B test/test_nrf52_ble_startup.py python3 -B test/test_shared_mota_queue.py python3 -B test/test_cascade_release_package.py @@ -416,6 +435,11 @@ startup failures. The manual staging buffer also has allocation-failure and repeated release tests. ESP32 tests run the actual WiFi mOTA listener and source framing through complete transfers, idle polling, queue-full refusal, network loss, CLI detach and USB/TCP ownership changes under address/leak sanitizers. +The ST7735 tests check all 256 RGB332 values, exact UI primary colors, full +160x80 scanout and byte order on both SPI APIs, the unchanged 16-bit path, +allocation failures, and retained-buffer wake/retry behavior. CI also compiles +both ordinary T096 BLE FEM variants with their real runtime-RAM gates, not only +the distinct Full Companion overlay. For older releases without saved ELFs, an audit can compare their ESP allocator tables against a matching pinned SDK ELF and read reservations from the diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index d238b8c11..e5d277a51 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -27,7 +27,6 @@ from firmware_elf import FirmwareElf SUPPORTED = {"NRF52_PLATFORM", "ESP32_PLATFORM", "RP2040_PLATFORM", "STM32_PLATFORM"} DISPLAY_HEAP = { "": 0, "NullDisplayDriver": 0, - "ST7735Display": 25602, "SSD1306Display": 4096, "SH1106Display": 4096, "U8g2Display": 4096, "SH1107Display": 4096, # These drivers render directly or embed their pixel storage in globals. @@ -144,6 +143,17 @@ 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 == "ST7735Display": + depth = integer(defines, "ST7735_BUFFER_COLOR_DEPTH", 16) + if depth not in (8, 16): + raise ValueError("ST7735_BUFFER_COLOR_DEPTH must be 8 or 16") + # TFT_eSprite adds one trailing pixel. The opt-in RGB332 path also + # source-bounds its complete sprite object plus allocator metadata to + # 1 KiB. Its 320-byte wire scanline lives on the unchanged loop stack; + # the existing transient allowance remains additive. + display_heap = (160 * 80 + 1) * (depth // 8) + if depth == 8: + display_heap += 1024 elif display in DISPLAY_HEAP: display_heap = DISPLAY_HEAP[display] else: diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp index 20ad54b3c..b2ba281c1 100644 --- a/src/helpers/ui/ST7735Display.cpp +++ b/src/helpers/ui/ST7735Display.cpp @@ -5,6 +5,21 @@ #endif #include +// Most ST7735 recipes keep the original RGB565 canvas. RAM-constrained +// targets can retain the complete UI in RGB332 and expand only one scanline +// while sending it to the panel; the panel still receives RGB565 pixels. +#ifndef ST7735_BUFFER_COLOR_DEPTH + #define ST7735_BUFFER_COLOR_DEPTH 16 +#endif +static_assert(ST7735_BUFFER_COLOR_DEPTH == 8 || ST7735_BUFFER_COLOR_DEPTH == 16, + "ST7735_BUFFER_COLOR_DEPTH must be 8 or 16"); +#if ST7735_BUFFER_COLOR_DEPTH == 8 +// Bind the separate sprite/allocator allowance to the actual dependency. The +// framebuffer itself is 160*80+1 bytes (including TFT_eSPI's sentinel pixel). +static_assert(sizeof(TFT_eSprite) + 2 * 16 <= 1024, + "Increase the ST7735 sprite-object runtime RAM allowance"); +#endif + #if defined(NRF52_PLATFORM) #include #endif @@ -452,6 +467,7 @@ bool ST7735Display::begin() { mesh::usbLoggingPort().println("ST7735Display: failed to alloc canvas"); return false; } + sprite->setColorDepth(ST7735_BUFFER_COLOR_DEPTH); if (!sprite->createSprite(160, 80)) { mesh::usbLoggingPort().println( "ST7735Display: failed to alloc canvas pixels"); @@ -669,6 +685,25 @@ void ST7735Display::endFrame() { set_CS(LOW); _spi->beginTransaction(_spiSettings); uint16_t x, y; +#if ST7735_BUFFER_COLOR_DEPTH == 8 + const uint8_t* pixels = static_cast(sprite->getPointer()); + // This bounded stack buffer replaces a second full-size canvas. Explicit + // wire bytes preserve the panel's high-byte-first order on every MCU. + uint8_t scanline[160 * 2]; + for (y = 0; y < 80; y++, pixels += 160) { + for (x = 0; x < 160; ++x) { + const uint16_t color = lcd.color8to16(pixels[x]); + scanline[2 * x] = static_cast(color >> 8); + scanline[2 * x + 1] = static_cast(color); + } + setAddrWindow(0, y, 160, 1); +#ifdef ESP_PLATFORM + _spi->transferBytes(scanline, NULL, sizeof(scanline)); +#else + _spi->transfer(scanline, NULL, sizeof(scanline)); +#endif + } +#else uint16_t* pixels = (uint16_t *) ((TFT_eSprite *) sprite)->getPointer(); for (y = 0; y < 80; y++, pixels += 160) { setAddrWindow(0, y, 160, 1); @@ -678,6 +713,7 @@ void ST7735Display::endFrame() { _spi->transfer(pixels, NULL, 2 * 160); #endif } +#endif _spi->endTransaction(); set_CS(HIGH); diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 616a9a41f..68b8321a6 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -121,6 +121,49 @@ class FirmwareRamTest(unittest.TestCase): }, "muzi_base_uno_superIO_companion_radio_ble") self.assertEqual(policy["components"]["display_pixels_and_driver"], 4096) + def test_t096_legacy_ble_rgb332_fits_without_reducing_other_reserves(self): + flags = {"DISPLAY_CLASS": "ST7735Display", "BLE_PIN_CODE": 123456, + "ENABLE_OTA": 1} + for suffix in ("femon", "femoff"): + target = "Heltec_t096_companion_radio_ble_" + suffix + with self.subTest(target=target): + original = ram.requirements("NRF52_PLATFORM", flags, target) + compact = ram.requirements("NRF52_PLATFORM", { + **flags, "ST7735_BUFFER_COLOR_DEPTH": 8, + }, target) + self.assertEqual(original["required_heap_bytes"], 81474) + self.assertEqual(compact["required_heap_bytes"], 69697) + self.assertEqual(compact["components"]["display_pixels_and_driver"], 12801 + 1024) + self.assertGreater(original["required_heap_bytes"], 75396) + self.assertLess(compact["required_heap_bytes"], 75396) + for name, value in original["components"].items(): + if name != "display_pixels_and_driver": + self.assertEqual(compact["components"][name], value) + self.assertEqual(compact["components"]["expanded_message_previews"], 2816) + self.assertEqual(compact["components"]["ota_generic_source_buffers"], 6176) + self.assertEqual(compact["components"]["allocation_and_transient_margin"], 4096) + + def test_st7735_color_depth_default_and_invalid_values_fail_closed(self): + flags = {"DISPLAY_CLASS": "ST7735Display"} + default = ram.requirements("NRF52_PLATFORM", flags, "other_companion") + explicit = ram.requirements("NRF52_PLATFORM", { + **flags, "ST7735_BUFFER_COLOR_DEPTH": 16, + }, "other_companion") + self.assertEqual(default, explicit) + self.assertEqual(default["components"]["display_pixels_and_driver"], 25602) + for depth in (0, 1, 4, 12, 24, 32): + with self.subTest(depth=depth), self.assertRaisesRegex(ValueError, "ST7735_BUFFER_COLOR_DEPTH"): + ram.requirements("NRF52_PLATFORM", { + **flags, "ST7735_BUFFER_COLOR_DEPTH": depth, + }, "other_companion") + + def test_t096_canvas_configuration_does_not_reduce_companion_capacities(self): + config = (ROOT / "variants/heltec_t096/platformio.ini").read_text() + self.assertIn("-D ST7735_BUFFER_COLOR_DEPTH=8", config) + self.assertIn("-D MAX_CONTACTS=350", config) + self.assertIn("-D MAX_GROUP_CHANNELS=40", config) + self.assertIn("-D OFFLINE_QUEUE_SIZE=256", config) + def test_wio_l2_counts_internal_sprite_fallback_and_dma_beside_psram(self): flags = {"DISPLAY_CLASS": "WioTrackerL2Display", "UI_ZOOM": 2} for target in ("Wio_Tracker_L2_repeater", "Wio_Tracker_L2_companion_radio_ble"): diff --git a/test/test_nrf52_ble_startup.py b/test/test_nrf52_ble_startup.py index 6828e1239..20c98cd34 100644 --- a/test/test_nrf52_ble_startup.py +++ b/test/test_nrf52_ble_startup.py @@ -31,6 +31,7 @@ HARNESS = r''' #include #include #include +#include #ifndef COMPANION_FEATURE_BLE_MOTA_SOURCE #define COMPANION_FEATURE_BLE_MOTA_SOURCE 1 #endif @@ -194,7 +195,17 @@ int main() { // services are extensions: registration failure must leave UART BLE // available for pairing, diagnostics, and recovery. const bool expected = fault != 1 && fault != 2; + auto& sleep_state = mesh_nrf52::sleepCycleState(); + const uint32_t before_start = sleep_state.begin(); + assert(sleep_state.publish(before_start, false, 42)); + mesh_nrf52::SleepCycleSnapshot snapshot; + assert(sleep_state.snapshot(snapshot)); + assert(snapshot.generation == before_start); assert(port.begin("MC-", "T096", 123456) == expected); + // Even a failed/partial SoftDevice start invalidates the pre-start view: + // the next sleep decision must query and publish fresh SDK state. + assert(!sleep_state.current(before_start)); + assert(!sleep_state.snapshot(snapshot)); assert(Bluefruit.mtu == COMPANION_BLE_PRPH_MTU); assert(Bluefruit.event_length == COMPANION_BLE_PRPH_EVENT_LENGTH); assert(Bluefruit.hvn_queue == COMPANION_BLE_PRPH_HVN_QUEUE); @@ -202,9 +213,18 @@ int main() { assert(Bluefruit.starts == starts + 1); assert((instance == &port) == expected); // Retrying a failed partial init must never allocate another stack/FIFO. + // Likewise, an already-attempted begin does not invalidate a fresh sleep + // snapshot because it never attempts SoftDevice startup again. for (int retry = 0; retry < 4; ++retry) { + const uint32_t before_retry = sleep_state.begin(); + assert(sleep_state.publish(before_retry, expected, 43 + retry)); assert(port.begin("MC-", "T096", 123456) == expected); assert(Bluefruit.starts == starts + 1); + assert(sleep_state.current(before_retry)); + assert(sleep_state.snapshot(snapshot)); + assert(snapshot.generation == before_retry); + assert(snapshot.softdevice_enabled == expected); + assert(snapshot.usb_status == unsigned(43 + retry)); } } } diff --git a/test/test_st7735_framebuffer.py b/test/test_st7735_framebuffer.py new file mode 100644 index 000000000..5ce2d63e3 --- /dev/null +++ b/test/test_st7735_framebuffer.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +"""Exercise real ST7735 allocation, RGB332 scanout, and retained wake paths. + +The fixture records SPI wire bytes instead of driving a physical panel. It +compiles the production methods for both framebuffer depths and SPI APIs. +""" + +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from cpp_source import body +from test_serial_wifi_sessions import SANITIZER_FLAGS + + +ROOT = Path(__file__).resolve().parents[1] +DRIVER = ROOT / "src/helpers/ui/ST7735Display.cpp" + + +HARNESS = r''' +#include +#include +#include +#include +#include +#include + +constexpr int INPUT = 0, OUTPUT = 1, LOW = 0, HIGH = 1; +constexpr int PIN_TFT_RST = 1, PIN_TFT_CS = 2, PIN_TFT_DC = 3; +constexpr int PIN_TFT_LEDA_CTL = 4, PIN_TFT_SDA = 5, PIN_TFT_SCL = 6; +constexpr int PIN_TFT_LEDA_CTL_ACTIVE = HIGH, DISPLAY_ROTATION = 1; +constexpr int SPI_CLOCK_DIV2 = 2, ST77XX_NORON = 0x13; +constexpr int ST77XX_DISPON = 0x29, ST77XX_DISPOFF = 0x28, ST77XX_SLPIN = 0x10; +constexpr uint16_t ST77XX_BLACK = 0, ST77XX_WHITE = 0xffff; +constexpr int NRF_GPIO_PIN_DIR_OUTPUT = 0, NRF_GPIO_PIN_INPUT_CONNECT = 1; +constexpr int NRF_GPIO_PIN_INPUT_DISCONNECT = 2, NRF_GPIO_PIN_NOPULL = 3; +constexpr int NRF_GPIO_PIN_H0H1 = 4, NRF_GPIO_PIN_NOSENSE = 5; +static unsigned gpio_calls, high_drive_calls, errors, resets; +static unsigned objects, destroyed, allocations, fills, color_calls; +static bool fail_pixels; +static std::vector wire; +static unsigned transactions, finishes, windows, cs_calls; + +void digitalWrite(int, int) { ++gpio_calls; } +void pinMode(int, int) { ++gpio_calls; } +void delay(unsigned) {} +void nrf_gpio_cfg(int, int, int, int, int, int) { ++high_drive_calls; } +void sendCommand(int) {} +void set_CS(int) { ++cs_calls; } +void setAddrWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h) { + assert(x == 0 && y == windows && w == 160 && h == 1); + ++windows; +} +static void resetWire() { + wire.clear(); transactions = finishes = windows = cs_calls = color_calls = 0; +} +struct Spi { + void begin() {} + void begin(int, int, int, int) {} + void setClockDivider(int) {} + void beginTransaction(int) { ++transactions; } + void endTransaction() { ++finishes; } + void transfer(const void* data, void* output, size_t length) { + assert(output == nullptr && length == 320); + auto* bytes = static_cast(data); + wire.insert(wire.end(), bytes, bytes + length); + } + void transferBytes(const uint8_t* data, uint8_t* output, size_t length) { + transfer(data, output, length); + } +} spi; +static Spi* _spi = &spi; +static int _spiSettings; +static int _height, _width, _colstart, _rowstart; +struct TFT_eSPI { + // Same public RGB332-to-RGB565 mapping as the pinned TFT_eSPI dependency. + uint16_t color8to16(uint8_t color) { + ++color_calls; + const uint8_t blue[] = {0, 11, 21, 31}; + return (color & 0x1c) << 6 | (color & 0xc0) << 5 + | (color & 0xe0) << 8 | (color & 0x1c) << 3 | blue[color & 3]; + } +} lcd; +struct TFT_eSprite { + int depth = 16; + bool depth_selected = false; + bool valid = false; + std::vector pixels; + explicit TFT_eSprite(TFT_eSPI*) { ++objects; } + ~TFT_eSprite() { ++destroyed; } + void setColorDepth(int value) { + assert(!valid && pixels.empty()); + depth = value; depth_selected = true; + } + void* createSprite(int width, int height) { + assert(depth_selected && width == 160 && height == 80); + ++allocations; + if (fail_pixels) { fail_pixels = false; return nullptr; } + pixels.resize((width * height + 1) * (depth / 8)); + valid = true; + return getPointer(); + } + void* getPointer() { return valid ? pixels.data() : nullptr; } + void fillScreen(uint16_t color) { + assert(valid && color == 0); ++fills; + std::fill(pixels.begin(), pixels.end(), 0); + } + void setTextColor(uint16_t color) { assert(color == 0xffff); } +}; +@DEPTH_POLICY@ +static TFT_eSprite* sprite; +static uint32_t curr_color; +@READY@ +namespace mesh { +struct Logger { void println(const char*) { ++errors; } } logger; +Logger& usbLoggingPort() { return logger; } +} +struct Rail { + unsigned claims = 0; + void claim() { ++claims; } + void release() { assert(claims); --claims; } +}; +class ST7735Display { +public: + bool _isOn = false; + Rail* _peripher_power; + explicit ST7735Display(Rail* rail) : _peripher_power(rail) {} + bool begin(); + void turnOn(); + void turnOff(); + void endFrame(); + void _resetAndInit() { ++resets; } +}; +@METHODS@ + +static uint16_t expandExpected(uint8_t value) { + // Independent component arithmetic, not the driver's bit-expression. + unsigned r = value >> 5, g = value >> 2 & 7, b = value & 3; + unsigned red = r * 4 + r / 2; + unsigned green = g * 8 + g; + unsigned blue = (b * 31 + 2) / 3; + return static_cast((red << 11) | (green << 5) | blue); +} +static uint8_t pack332(uint16_t value) { + return static_cast((value >> 8 & 0xe0) + | (value >> 6 & 0x1c) | (value >> 3 & 3)); +} +static void checkWire() { + assert(transactions == 1 && finishes == 1 && windows == 80 && cs_calls == 2); + assert(wire.size() == 160 * 80 * 2); + for (size_t pixel = 0; pixel < 160 * 80; ++pixel) { +#if ST7735_BUFFER_COLOR_DEPTH == 8 + uint16_t expected = expandExpected(static_cast(pixel)); + assert(wire[2 * pixel] == expected >> 8); + assert(wire[2 * pixel + 1] == (expected & 0xff)); +#else + // RGB565 storage is already in panel wire order; retain every byte. + assert(wire[2 * pixel] == static_cast(pixel >> 5)); + assert(wire[2 * pixel + 1] == static_cast(pixel)); +#endif + } +#if ST7735_BUFFER_COLOR_DEPTH == 8 + assert(color_calls == 160 * 80); +#else + assert(color_calls == 0); +#endif +} +int main() { + Rail rail; + ST7735Display display(&rail); + // An absent or failed canvas must never be dereferenced or touch SPI. + display.endFrame(); + assert(wire.empty() && transactions == 0); + fail_pixels = true; + assert(!display.begin()); + assert(sprite == nullptr && objects == 1 && destroyed == 1); + assert(errors == 1 && gpio_calls == 0 && rail.claims == 0); + + assert(display.begin()); + assert(objects == 2 && allocations == 2 && fills == 1); + assert(sprite->pixels.size() == (160 * 80 + 1) * (ST7735_BUFFER_COLOR_DEPTH / 8)); + assert(display._isOn && rail.claims == 1 && resets == 1); + assert(wire.size() == 160 * 80 * 2); + assert(std::all_of(wire.begin(), wire.end(), [](uint8_t value) { return value == 0; })); +#if defined(NRF52_PLATFORM) + assert(high_drive_calls == 2); +#endif + const unsigned first_gpio_calls = gpio_calls; + const void* original_pixels = sprite->getPointer(); + assert(display.begin()); + assert(objects == 2 && allocations == 2 && fills == 1 && resets == 1); + assert(gpio_calls == first_gpio_calls && rail.claims == 1); + + // Every one of the 256 RGB332 codes appears repeatedly, including row + // boundaries. The sentinel byte/pixel is not transmitted. + for (size_t pixel = 0; pixel < 160 * 80; ++pixel) { +#if ST7735_BUFFER_COLOR_DEPTH == 8 + sprite->pixels[pixel] = static_cast(pixel); +#else + sprite->pixels[2 * pixel] = static_cast(pixel >> 5); + sprite->pixels[2 * pixel + 1] = static_cast(pixel); +#endif + } + resetWire(); + display.endFrame(); + checkWire(); + + // The UI's actual eight named colors remain exact, not merely similar. + for (uint16_t color : {0x0000, 0xffff, 0xf800, 0x07e0, + 0x001f, 0x07ff, 0xf81f, 0xffe0}) { + assert(expandExpected(pack332(color)) == color); + } + + // Sleep/wake repairs the bus but does not allocate, clear, or replace the + // retained frame. A second wake while already on is a complete no-op. + display.turnOff(); + assert(!display._isOn && rail.claims == 0); + resetWire(); + display.turnOn(); + checkWire(); + assert(display._isOn && rail.claims == 1 && resets == 2); + assert(sprite->getPointer() == original_pixels); + assert(objects == 2 && allocations == 2 && fills == 1); + resetWire(); + display.turnOn(); + assert(wire.empty() && transactions == 0 && allocations == 2); + + // Corrupt/failed library canvas state also fails closed before any SPI or + // reinitialization. It is not silently reinterpreted as another depth. + sprite->valid = false; + resetWire(); + assert(!display.begin()); + display.endFrame(); + assert(wire.empty() && transactions == 0 && allocations == 2); + delete sprite; + sprite = nullptr; +} +''' + + +class ST7735FramebufferTests(unittest.TestCase): + def source(self): + driver = DRIVER.read_text(encoding="utf-8") + start = driver.index("#ifndef ST7735_BUFFER_COLOR_DEPTH") + end = driver.index("#if defined(NRF52_PLATFORM)", start) + methods = "\n".join(body(driver, signature) for signature in ( + "bool ST7735Display::begin()", "void ST7735Display::endFrame()", + "void ST7735Display::turnOff()", "void ST7735Display::turnOn()")) + return (HARNESS.replace("@DEPTH_POLICY@", driver[start:end]) + .replace("@READY@", body(driver, "static bool spriteReady()")) + .replace("@METHODS@", methods)) + + def test_depths_spi_wire_order_allocation_and_wake(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + with tempfile.TemporaryDirectory(prefix="meshcore-st7735-buffer-") as directory: + cpp = Path(directory) / "framebuffer.cpp" + cpp.write_text(self.source(), encoding="utf-8") + for depth in (8, 16): + for platform in ("ESP_PLATFORM", "NRF52_PLATFORM"): + with self.subTest(depth=depth, platform=platform): + binary = Path(directory) / f"framebuffer-{depth}-{platform}.exe" + result = subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-variable", "-Wno-unused-function", *SANITIZER_FLAGS, + f"-DST7735_BUFFER_COLOR_DEPTH={depth}", f"-D{platform}=1", + str(cpp), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, + text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_unsupported_depth_is_rejected(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + with tempfile.TemporaryDirectory(prefix="meshcore-st7735-depth-") as directory: + cpp = Path(directory) / "invalid.cpp" + cpp.write_text(self.source(), encoding="utf-8") + result = subprocess.run([ + compiler, "-std=c++17", "-DST7735_BUFFER_COLOR_DEPTH=4", "-c", + str(cpp), "-o", str(Path(directory) / "invalid.o"), + ], capture_output=True, text=True, timeout=60) + self.assertNotEqual(result.returncode, 0) + self.assertIn("ST7735_BUFFER_COLOR_DEPTH must be 8 or 16", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/heltec_t096/platformio.ini b/variants/heltec_t096/platformio.ini index ff911913b..e53f87fbd 100644 --- a/variants/heltec_t096/platformio.ini +++ b/variants/heltec_t096/platformio.ini @@ -49,6 +49,9 @@ build_flags = ${rak4631_hw.build_flags} -D PIN_VBAT_READ=BATTERY_PIN -D PIN_BAT_CTL=47 -D DISPLAY_CLASS=ST7735Display + ; The UI uses RGB332-exact primary colors. Keep every 160x80 pixel and + ; expand one scanline to RGB565 on SPI, saving 12,801 bytes of canvas RAM. + -D ST7735_BUFFER_COLOR_DEPTH=8 -D DISPLAY_ROTATION=1 ; The 160x80 T096 has no touch/navigation control for the message channel ; selector, so let message text use its 24 logical (30 physical) rows.