From a944b1779d98acc87d39aebedabf3b68e4a3e5a3 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 18 Oct 2026 23:18:05 -0700 Subject: [PATCH] Keep T-Deck radio2 scanning active during screen redraws Render into a PSRAM canvas and release the shared SPI bus between bounded TFT strips. Service only the radio driver at cooperative checkpoints and use the buffered radio HAL, preserving packet and transmit guards. Cover pixel parity, transaction boundaries, allocation failure, partial updates and sleep/wake behavior with native tests. --- .github/workflows/run-unit-tests.yml | 1 + src/helpers/ui/ST7789LCDDisplay.cpp | 272 ++++++++++++++++++++- src/helpers/ui/ST7789LCDDisplay.h | 31 +++ test/test_heltec_r8_display_detection.py | 5 +- test/test_tdeck_display_cooperation.py | 294 +++++++++++++++++++++++ variants/lilygo_tdeck/target.cpp | 18 +- 6 files changed, 607 insertions(+), 14 deletions(-) create mode 100644 test/test_tdeck_display_cooperation.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 6b6a99ace..a272ec7ec 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -969,6 +969,7 @@ jobs: python3 -B test/test_wireless_tracker_display_detection.py -v python3 -B test/test_heltec_r8_display_detection.py -v python3 -B test/test_heltec_r8_display_rotation.py -v + python3 -B test/test_tdeck_display_cooperation.py -v python3 -B test/test_muzi_accessory_detection.py -v python3 -B test/test_techo_lite_display_detection.py -v python3 -B test/test_companion_headless_buttons.py -v diff --git a/src/helpers/ui/ST7789LCDDisplay.cpp b/src/helpers/ui/ST7789LCDDisplay.cpp index 54e64e489..28f3e75db 100644 --- a/src/helpers/ui/ST7789LCDDisplay.cpp +++ b/src/helpers/ui/ST7789LCDDisplay.cpp @@ -1,6 +1,154 @@ #include "ST7789LCDDisplay.h" #include "ColorTheme.h" +#ifdef LILYGO_TDECK +#include +#include +#include +#include + +// Adafruit_GFX supplies text and bitmap rasterization without allocating its +// own framebuffer. The 153,600-byte RGB565 frame must live in external RAM. +class TDeckCanvas : public Adafruit_GFX { + ST7789LCDDisplay& _owner; + uint16_t* _pixels; + uint16_t _pixel_work = 0; + + void cooperate(uint16_t pixels) { + _pixel_work += pixels; + if (_pixel_work >= 128) { + _pixel_work = 0; + _owner.serviceRender(); + } + } + +public: + TDeckCanvas(ST7789LCDDisplay& owner, int w, int h) + : Adafruit_GFX(w, h), _owner(owner), + _pixels(static_cast(heap_caps_calloc( + size_t(w) * h, sizeof(uint16_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT))) { + setTextWrap(false); + cp437(true); + } + ~TDeckCanvas() { heap_caps_free(_pixels); } + uint16_t* pixels() const { return _pixels; } + + void drawPixel(int16_t x, int16_t y, uint16_t color) override { + if (_pixels && x >= 0 && y >= 0 && x < WIDTH && y < HEIGHT) + _pixels[size_t(y) * WIDTH + x] = color; + cooperate(1); + } + void drawFastHLine(int16_t x, int16_t y, int16_t w, uint16_t color) override { + if (!_pixels || y < 0 || y >= HEIGHT || w <= 0) return; + const int left = x < 0 ? 0 : x; + const int right = int(x) + w > WIDTH ? WIDTH : int(x) + w; + if (left >= right) return; + uint16_t* row = _pixels + size_t(y) * WIDTH; + for (int xx = left; xx < right; ++xx) row[xx] = color; + cooperate(right - left); + } + void drawFastVLine(int16_t x, int16_t y, int16_t h, uint16_t color) override { + if (!_pixels || x < 0 || x >= WIDTH || h <= 0) return; + const int top = y < 0 ? 0 : y; + const int bottom = int(y) + h > HEIGHT ? HEIGHT : int(y) + h; + for (int yy = top; yy < bottom; ++yy) { + _pixels[size_t(yy) * WIDTH + x] = color; + cooperate(1); + } + } + void fillRect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color) override { + if (w <= 0 || h <= 0) return; + const int top = y < 0 ? 0 : y; + const int bottom = int(y) + h > HEIGHT ? HEIGHT : int(y) + h; + for (int yy = top; yy < bottom; ++yy) drawFastHLine(x, yy, w, color); + } + void fillScreen(uint16_t color) override { fillRect(0, 0, WIDTH, HEIGHT, color); } + void writeFastHLine(int16_t x, int16_t y, int16_t w, uint16_t color) override { + drawFastHLine(x, y, w, color); + } + void writeFastVLine(int16_t x, int16_t y, int16_t h, uint16_t color) override { + drawFastVLine(x, y, h, color); + } + void writeFillRect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color) override { + fillRect(x, y, w, h, color); + } +}; + +ST7789LCDDisplay::~ST7789LCDDisplay() { delete _canvas; } + +bool ST7789LCDDisplay::prepareCanvas() { + if (_canvas_attempted) return _canvas != nullptr; + _canvas_attempted = true; + _canvas = new (std::nothrow) TDeckCanvas(*this, width(), height()); + if (!_canvas || !_canvas->pixels()) { + delete _canvas; + _canvas = nullptr; + mesh::usbConsolePort().println("WARN: T-Deck display PSRAM allocation failed; using direct redraw"); + return false; + } + return true; +} + +bool ST7789LCDDisplay::isBufferedRendering() const { + return _buffered_rendering && _canvas != nullptr; +} + +void ST7789LCDDisplay::setBufferedRendering(bool enabled) { + _frame_active = false; + _buffered_rendering = enabled; + if (enabled && _panel_ready) prepareCanvas(); +} + +void ST7789LCDDisplay::serviceRender(bool force) { + if (!_buffered_rendering || !_render_yield || _render_callback_active) return; + if (!force && uint32_t(micros() - _last_render_yield_us) < 250) return; + _render_callback_active = true; + _render_yield(); + _render_callback_active = false; + _last_render_yield_us = micros(); +} + +void ST7789LCDDisplay::flushCanvas(int x, int y, int w, int h) { + if (!isBufferedRendering() || !_isOn || w <= 0 || h <= 0) return; + const int right = int32_t(x) + w > width() ? width() : int32_t(x) + w; + const int bottom = int32_t(y) + h > height() ? height() : int32_t(y) + h; + x = x < 0 ? 0 : x; + y = y < 0 ? 0 : y; + w = right - x; + if (w <= 0 || y >= bottom) return; + + // At most 960 pixels (0.384 ms at 40 MHz), plus command/driver overhead, + // per transaction. Copy out of PSRAM so SPI never depends on DMA from it. + constexpr size_t StripPixels = 960; + uint16_t strip[StripPixels]; + const int rows_per_strip = StripPixels / w; + serviceRender(true); + while (y < bottom) { + const int rows = bottom - y < rows_per_strip ? bottom - y : rows_per_strip; + for (int row = 0; row < rows; ++row) { + memcpy(strip + size_t(row) * w, + _canvas->pixels() + size_t(y + row) * width() + x, + size_t(w) * sizeof(uint16_t)); + } + display.startWrite(); + display.setAddrWindow(x, y, w, rows); + display.writePixels(strip, size_t(w) * rows, true); + display.endWrite(); + // CS is high and the transfer is complete before the radio can use the + // shared physical clock/data pins. No recursive application-loop call. + serviceRender(true); + y += rows; + } +} + +void ST7789LCDDisplay::finishCanvasPrimitive(int x, int y, int w, int h) { + serviceRender(); + // Dashboard rows can be updated without startFrame/endFrame. Preserve their + // immediate behavior while sending only their changed rectangle in strips. + if (!_frame_active) flushCanvas(x, y, w, h); +} +#endif + #ifndef PIN_TFT_MISO #define PIN_TFT_MISO -1 #endif @@ -93,6 +241,16 @@ bool ST7789LCDDisplay::begin() { if (!_probe_complete) mesh::ui::heltec_r8::backlightOff(); #endif if (!_isOn) { + #ifdef LILYGO_TDECK + // This board has no TFT reset pin or separately switched panel supply. + // Blanking its backlight does not require a slow controller reinit on wake. + if (_panel_ready) { + if (_peripher_power) _peripher_power->claim(); + if (PIN_TFT_LEDA_CTL != -1) digitalWrite(PIN_TFT_LEDA_CTL, HIGH); + _isOn = true; + return true; + } + #endif #ifdef HELTEC_V4_R8_TFT // turnOff() leaves this panel configured and powered - its reset line is // shared with the touch controller, so it is never parked low - which makes @@ -186,6 +344,9 @@ bool ST7789LCDDisplay::begin() { _panel_ready = true; _isOn = true; +#ifdef LILYGO_TDECK + if (_buffered_rendering) prepareCanvas(); +#endif } return true; @@ -224,15 +385,35 @@ void ST7789LCDDisplay::turnOff() { } void ST7789LCDDisplay::clear() { -#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION +#if defined(LILYGO_TDECK) || defined(MESHCORE_HELTEC_R8_TFT_DETECTION) if (!_isOn) return; +#endif +#ifdef LILYGO_TDECK + if (isBufferedRendering()) { + if (!_isOn) return; + _canvas->fillScreen(ST77XX_BLACK); + finishCanvasPrimitive(0, 0, width(), height()); + return; + } #endif display.fillScreen(ST77XX_BLACK); } void ST7789LCDDisplay::startFrame(ColorVal bkg) { -#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION +#if defined(LILYGO_TDECK) || defined(MESHCORE_HELTEC_R8_TFT_DETECTION) if (!_isOn) return; +#endif +#ifdef LILYGO_TDECK + if (isBufferedRendering()) { + if (!_isOn) return; + _frame_active = true; + serviceRender(true); + _canvas->fillScreen(bkg); + _canvas->setTextColor(_color = UIColor::primary_txt); + _canvas->setTextSize(1); + _canvas->cp437(true); + return; + } #endif display.fillScreen(bkg); display.setTextColor(_color = UIColor::primary_txt); @@ -241,58 +422,119 @@ void ST7789LCDDisplay::startFrame(ColorVal bkg) { } void ST7789LCDDisplay::setTextSize(int sz) { -#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION +#if defined(LILYGO_TDECK) || defined(MESHCORE_HELTEC_R8_TFT_DETECTION) if (!_isOn) return; +#endif +#ifdef LILYGO_TDECK + if (isBufferedRendering()) { + _canvas->setTextSize(sz > 0 ? sz : 1); + return; + } #endif display.setTextSize(sz > 0 ? sz : 1); } void ST7789LCDDisplay::setColor(ColorVal c) { -#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION +#if defined(LILYGO_TDECK) || defined(MESHCORE_HELTEC_R8_TFT_DETECTION) if (!_isOn) return; +#endif +#ifdef LILYGO_TDECK + if (isBufferedRendering()) { + _canvas->setTextColor(_color = c); + return; + } #endif display.setTextColor(_color = c); } void ST7789LCDDisplay::setCursor(int x, int y) { -#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION +#if defined(LILYGO_TDECK) || defined(MESHCORE_HELTEC_R8_TFT_DETECTION) if (!_isOn) return; +#endif +#ifdef LILYGO_TDECK + if (isBufferedRendering()) { + _canvas->setCursor(x, y); + return; + } #endif display.setCursor(x, y); } void ST7789LCDDisplay::print(const char* str) { -#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION +#if defined(LILYGO_TDECK) || defined(MESHCORE_HELTEC_R8_TFT_DETECTION) if (!_isOn) return; +#endif +#ifdef LILYGO_TDECK + if (isBufferedRendering()) { + if (!_isOn) return; + int16_t x, y; + uint16_t w, h; + _canvas->getTextBounds(str, _canvas->getCursorX(), _canvas->getCursorY(), &x, &y, &w, &h); + _canvas->print(str); + finishCanvasPrimitive(x, y, w, h); + return; + } #endif display.print(str); } void ST7789LCDDisplay::fillRect(int x, int y, int w, int h) { -#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION +#if defined(LILYGO_TDECK) || defined(MESHCORE_HELTEC_R8_TFT_DETECTION) if (!_isOn) return; +#endif +#ifdef LILYGO_TDECK + if (isBufferedRendering()) { + if (!_isOn) return; + _canvas->fillRect(x, y, w, h, _color); + finishCanvasPrimitive(x, y, w, h); + return; + } #endif display.fillRect(x, y, w, h, _color); } void ST7789LCDDisplay::drawRect(int x, int y, int w, int h) { -#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION +#if defined(LILYGO_TDECK) || defined(MESHCORE_HELTEC_R8_TFT_DETECTION) if (!_isOn) return; +#endif +#ifdef LILYGO_TDECK + if (isBufferedRendering()) { + if (!_isOn) return; + _canvas->drawRect(x, y, w, h, _color); + finishCanvasPrimitive(x, y, w, h); + return; + } #endif display.drawRect(x, y, w, h, _color); } void ST7789LCDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { -#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION +#if defined(LILYGO_TDECK) || defined(MESHCORE_HELTEC_R8_TFT_DETECTION) if (!_isOn) return; +#endif +#ifdef LILYGO_TDECK + if (isBufferedRendering()) { + if (!_isOn) return; + _canvas->drawBitmap(x, y, bits, w, h, _color); + finishCanvasPrimitive(x, y, w, h); + return; + } #endif // The common UI bitmaps are MSB-first, as expected by drawBitmap. display.drawBitmap(x, y, bits, w, h, _color); } uint16_t ST7789LCDDisplay::getTextWidth(const char* str) { -#ifdef MESHCORE_HELTEC_R8_TFT_DETECTION +#if defined(LILYGO_TDECK) || defined(MESHCORE_HELTEC_R8_TFT_DETECTION) if (!_isOn) return 0; +#endif +#ifdef LILYGO_TDECK + if (isBufferedRendering()) { + int16_t x, y; + uint16_t w, h; + _canvas->getTextBounds(str, 0, 0, &x, &y, &w, &h); + return w; + } #endif int16_t x1, y1; uint16_t w, h; @@ -301,5 +543,15 @@ uint16_t ST7789LCDDisplay::getTextWidth(const char* str) { } void ST7789LCDDisplay::endFrame() { +#ifdef LILYGO_TDECK + if (!_isOn) { + _frame_active = false; + return; + } + if (isBufferedRendering()) { + _frame_active = false; + flushCanvas(0, 0, width(), height()); + } +#endif // display.display(); } diff --git a/src/helpers/ui/ST7789LCDDisplay.h b/src/helpers/ui/ST7789LCDDisplay.h index 162bc5201..9c1a8e8b2 100644 --- a/src/helpers/ui/ST7789LCDDisplay.h +++ b/src/helpers/ui/ST7789LCDDisplay.h @@ -7,6 +7,10 @@ #include #include +#ifdef LILYGO_TDECK +class TDeckCanvas; +#endif + #ifndef DISPLAY_ROTATION #define DISPLAY_ROTATION 3 #endif @@ -45,6 +49,22 @@ class ST7789LCDDisplay : public DisplayDriver { static constexpr int kWidth = (DISPLAY_ROTATION & 1) ? 320 : 240; static constexpr int kHeight = (DISPLAY_ROTATION & 1) ? 240 : 320; +#ifdef LILYGO_TDECK + friend class TDeckCanvas; + TDeckCanvas* _canvas = nullptr; + bool _buffered_rendering = true; + bool _canvas_attempted = false; + bool _frame_active = false; + bool _render_callback_active = false; + void (*_render_yield)() = nullptr; + uint32_t _last_render_yield_us = 0; + + bool prepareCanvas(); + void serviceRender(bool force = false); + void flushCanvas(int x, int y, int w, int h); + void finishCanvasPrimitive(int x, int y, int w, int h); +#endif + uint8_t effectiveRotation() const; void applyRotation(); bool i2c_probe(TwoWire& wire, uint8_t addr); @@ -74,6 +94,17 @@ public: #endif bool begin(); +#ifdef LILYGO_TDECK + ~ST7789LCDDisplay(); + // Call only between frames. Disabling buffering retains the allocation for + // same-image comparisons with the original direct-to-panel renderer. + void setBufferedRendering(bool enabled); + bool isBufferedRendering() const; + // The callback runs synchronously with TFT CS released and no SPI transaction. + // It may service the radio, but must not redraw or re-enter the application loop. + void setRenderYieldCallback(void (*callback)()) { _render_yield = callback; } +#endif + bool isOn() override { return _isOn; } #ifdef MESHCORE_HELTEC_R8_RUNTIME_ROTATION bool supportsRotation() const override { return true; } diff --git a/test/test_heltec_r8_display_detection.py b/test/test_heltec_r8_display_detection.py index 9f9a6b0ee..252fc1e3b 100644 --- a/test/test_heltec_r8_display_detection.py +++ b/test/test_heltec_r8_display_detection.py @@ -246,7 +246,10 @@ class HeltecR8DisplayDetectionTest(unittest.TestCase): "Adafruit_GFX.h": "#pragma once\n", "Adafruit_ST7789.h": PANEL, "main.cpp": MAIN}.items(): (directory / name).write_text(source) - binary = directory / "probe" + if "-DLILYGO_TDECK" in flags: + from test_tdeck_display_cooperation import extend_legacy_stubs + extend_legacy_stubs(directory) + binary = directory / ("probe.exe" if os.name == "nt" else "probe") sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) compiled = subprocess.run([ diff --git a/test/test_tdeck_display_cooperation.py b/test/test_tdeck_display_cooperation.py new file mode 100644 index 000000000..82799e08e --- /dev/null +++ b/test/test_tdeck_display_cooperation.py @@ -0,0 +1,294 @@ +"""Run the production T-Deck renderer against a recording SPI panel and PSRAM.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_serial_wifi_sessions import SANITIZER_FLAGS + +ROOT = Path(__file__).resolve().parents[1] + +ARDUINO = r''' +#pragma once +#include +#include +#include +#include +#include +#include +#include +#define LOW 0 +#define HIGH 1 +#define OUTPUT 1 +#define HSPI 1 +#define PIN_TFT_CS 12 +#define PIN_TFT_DC 11 +#define PIN_TFT_SCL 40 +#define PIN_TFT_SDA 41 +#define PIN_TFT_RST -1 +#define PIN_TFT_LEDA_CTL 42 +inline uint32_t now_us=0; +inline unsigned initializations=0, direct_pixels=0, spi_begins=0; +inline bool transaction=false, cs_high=true; +inline int levels[64]={}; +inline void pinMode(int,int) {} +inline void digitalWrite(int pin,int value) { assert(pin>=0 && pin<64); levels[pin]=value; } +inline void delay(unsigned ms) { now_us+=ms*1000; } +inline uint32_t micros() { now_us+=25; return now_us; } +''' + +# This stand-in supplies GFX's primitive dispatch and cursor contract. The +# implementation under test is the real TDeckCanvas and LCD driver, including +# every framebuffer write, clipping decision, strip copy and callback boundary. +GFX = r''' +#pragma once +#include +#include +#include +#include +class Adafruit_GFX { +protected: + const int16_t WIDTH, HEIGHT; + int16_t cursor_x=0,cursor_y=0; + uint8_t textsize=1; + uint16_t textcolor=0xffff; +public: + Adafruit_GFX(int w,int h):WIDTH(w),HEIGHT(h) {} + virtual ~Adafruit_GFX()=default; + virtual void drawPixel(int16_t,int16_t,uint16_t)=0; + virtual void drawFastHLine(int16_t x,int16_t y,int16_t w,uint16_t c) { + for(int i=0;i>(col&7)))drawPixel(x+col,y+row,c); + } + void setTextWrap(bool) {} + void cp437(bool) {} + void setTextSize(uint8_t size) {textsize=size;} + void setTextColor(uint16_t c) {textcolor=c;} + void setCursor(int16_t x,int16_t y) {cursor_x=x;cursor_y=y;} + int16_t getCursorX() const {return cursor_x;} + int16_t getCursorY() const {return cursor_y;} + void getTextBounds(const char* s,int16_t x,int16_t y,int16_t* bx,int16_t* by,uint16_t* w,uint16_t* h) { + *bx=x;*by=y;*w=strlen(s)*6*textsize;*h=*w?8*textsize:0; + } + void print(const char* s) { + while(*s++) {writeFillRect(cursor_x,cursor_y,5*textsize,7*textsize,textcolor);cursor_x+=6*textsize;} + } +}; +''' + +PANEL = r''' +#pragma once +#include +#include +#define ST77XX_BLACK 0 +#define ST77XX_WHITE 0xffff +inline std::vector panel(320*240); +struct Window {int x,y,w,h;}; +inline std::vector windows; +class Adafruit_ST7789:public Adafruit_GFX { + int _w=320,_h=240; + Window window{}; +public: + template explicit Adafruit_ST7789(T...):Adafruit_GFX(320,240) {} + void init(int,int) {++initializations;} + void setRotation(int r) {_w=(r&1)?320:240;_h=(r&1)?240:320;} + int width()const{return _w;} int height()const{return _h;} + void setSPISpeed(double) {} + void drawPixel(int16_t x,int16_t y,uint16_t c) override { + ++direct_pixels; + if(x>=0 && y>=0 && x<_w && y<_h)panel[size_t(y)*_w+x]=c; + } + void startWrite() {assert(!transaction && cs_high);transaction=true;cs_high=false;} + void setAddrWindow(int x,int y,int w,int h) { + assert(transaction && !cs_high && x>=0 && y>=0 && w>0 && h>0 && x+w<=_w && y+h<=_h); + window={x,y,w,h};windows.push_back(window); + } + void writePixels(uint16_t* pixels,uint32_t count,bool blocking) { + assert(transaction && !cs_high && blocking && count==unsigned(window.w*window.h) && count<=960); + for(int row=0;row +#define MALLOC_CAP_SPIRAM 1 +#define MALLOC_CAP_8BIT 2 +inline bool fail_psram=false; +inline unsigned allocation_calls=0, frees=0; +inline size_t allocated_bytes=0; +inline uint8_t* allocation=nullptr; +inline void* heap_caps_calloc(size_t count,size_t size,unsigned caps) { + ++allocation_calls;assert(caps==(MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT)); + if(fail_psram)return nullptr; + assert(!allocation);allocated_bytes=count*size; + allocation=static_cast(malloc(allocated_bytes+64)); + memset(allocation,0xA5,allocated_bytes+64);memset(allocation+32,0,allocated_bytes); + return allocation+32; +} +inline void heap_caps_free(void* pointer) { + if(!pointer)return; + assert(pointer==allocation+32); + for(unsigned i=0;i<32;++i)assert(allocation[i]==0xA5 && allocation[allocated_bytes+32+i]==0xA5); + free(allocation);allocation=nullptr;++frees; +} +''' + +USB = r''' +#pragma once +#include +inline std::vector warnings; +namespace mesh { +struct Console {void println(const char* text){warnings.emplace_back(text);}}; +inline Console console; +inline Console& usbConsolePort(){return console;} +} +''' + +def write_stubs(directory): + """Also used by the R8 guards to compile the newly complete T-Deck branch.""" + (directory / "helpers").mkdir(exist_ok=True) + for name, source in { + "Arduino.h": ARDUINO, "Adafruit_GFX.h": GFX, + "Adafruit_ST7789.h": PANEL, "esp_heap_caps.h": HEAP, + "helpers/UsbLogging.h": USB, + "Wire.h": "#pragma once\nclass TwoWire {};\n", + "SPI.h": "#pragma once\n#include \nstruct SPIClass {explicit SPIClass(int=0) {} void begin(int,int,int,int){++spi_begins;}};\ninline SPIClass SPI;\n", + }.items(): + (directory / name).write_text(source) + + +def extend_legacy_stubs(directory): + """Keep legacy R8 guard cases compiling the actual T-Deck code as well.""" + (directory / "helpers").mkdir(exist_ok=True) + for name, source in {"Adafruit_GFX.h": GFX, "esp_heap_caps.h": HEAP, + "helpers/UsbLogging.h": USB}.items(): + (directory / name).write_text(source) + with (directory / "Arduino.h").open("a") as stream: + stream.write("\ninline uint32_t micros() {now_us+=25;return now_us;}\n") + panel_path = directory / "Adafruit_ST7789.h" + panel = panel_path.read_text() + panel = panel.replace("public:\n", "public:\n" + r''' + void startWrite() {assert(initialized);} + void endWrite() {assert(initialized);} + void setAddrWindow(int x,int y,int w,int h) { + assert(initialized && x>=0 && y>=0 && w>0 && h>0 && x+w<=_w && y+h<=_h); + } + void writePixels(uint16_t*,uint32_t count,bool blocking) { + assert(initialized && blocking && count<=960);++draws; + } +''', 1) + panel_path.write_text(panel) + + +MAIN = r''' +#include +#include +#include +inline unsigned callbacks=0, depth=0, max_depth=0; +inline ST7789LCDDisplay* current=nullptr; +inline bool reenter=false; +void cooperate() { + assert(!transaction && cs_high && "radio callback must run with completed TFT transaction"); + ++callbacks;++depth;max_depth=std::max(max_depth,depth); + if(reenter){reenter=false;current->fillRect(1,1,2,2);} + --depth; +} +void scene(ST7789LCDDisplay& d) { + const uint8_t bits[]={0x81,0x42}; + d.startFrame(0x1234);d.setColor(0xCAFE); + d.fillRect(-3,-4,8,9);d.fillRect(318,237,20,20); + d.drawRect(20,20,9,7);d.drawRect(-2,60,6,7); + d.drawXbm(35,35,bits,8,2);d.setTextSize(2);d.setCursor(60,60);d.print("AB");d.endFrame(); +} +int main(int argc,char** argv) { + assert(argc==2);const std::string scenario=argv[1]; + fail_psram=scenario=="allocation-failure"; + { + ST7789LCDDisplay d;current=&d;d.setRenderYieldCallback(cooperate); + assert(!d.isBufferedRendering());assert(d.begin()); + assert(allocation_calls==1 && d.isBufferedRendering()==!fail_psram); + if(fail_psram) { + assert(warnings.size()==1 && warnings[0].find("PSRAM allocation failed")!=std::string::npos); + scene(d);assert(direct_pixels>0 && callbacks==0 && windows.empty()); + d.setBufferedRendering(false);d.setBufferedRendering(true);d.turnOff();d.turnOn(); + assert(allocation_calls==1 && warnings.size()==1 && initializations==1); + } else { + assert(allocated_bytes==153600 && warnings.empty()); + windows.clear();const auto direct_before=direct_pixels; + d.startFrame(0x1234);d.setColor(0xCAFE);d.fillRect(-3,-4,8,9); + assert(windows.empty() && direct_pixels==direct_before && callbacks>0); + d.endFrame();assert(windows.size()==80); + for(const auto& w:windows)assert(w.x==0 && w.w==320 && w.h==3); + assert(panel[0]==0xCAFE && panel[4*320+4]==0xCAFE && panel[5*320+5]==0x1234); + windows.clear();d.fillRect(-2,238,7,9); + assert(windows.size()==1 && windows[0].x==0 && windows[0].y==238 && windows[0].w==5 && windows[0].h==2); + assert(panel[239*320+4]==0xCAFE && panel[239*320+5]==0x1234); + windows.clear();d.fillRect(900,900,10,10);d.fillRect(0,0,-2,5);assert(windows.empty()); + scene(d);const auto buffered=panel;const auto buffered_callbacks=callbacks; + d.setBufferedRendering(false);assert(!d.isBufferedRendering());scene(d); + assert(panel==buffered && callbacks==buffered_callbacks && direct_pixels>direct_before); + d.setBufferedRendering(true);assert(d.isBufferedRendering() && allocation_calls==1);scene(d); + assert(panel==buffered && callbacks>buffered_callbacks); + reenter=true;d.startFrame(0x0010);d.endFrame();assert(max_depth==1 && !reenter); + // Unframed text must flush the actual cursor rectangle, not a full screen. + windows.clear();d.setTextSize(1);d.setCursor(20,30);d.print("A"); + assert(windows.size()==1 && windows[0].x==20 && windows[0].y==30 && windows[0].w==6 && windows[0].h==8); + } + for(bool buffered:{true,false}) { + d.setBufferedRendering(buffered);d.turnOff(); + const auto old_panel=panel;const auto draws=direct_pixels;const auto yields=callbacks;windows.clear(); + scene(d);d.clear();assert(panel==old_panel && direct_pixels==draws && callbacks==yields && windows.empty()); + assert(!d.isOn() && levels[PIN_TFT_LEDA_CTL]==LOW); + d.turnOn();assert(d.isOn() && initializations==1 && spi_begins==1 && levels[PIN_TFT_LEDA_CTL]==HIGH); + } + } + assert(allocation==nullptr && frees==unsigned(!fail_psram)); +} +''' + + +class TDeckDisplayCooperationTests(unittest.TestCase): + def test_production_renderer_pixels_transactions_psram_fallback_and_sleep(self): + with tempfile.TemporaryDirectory(prefix="tdeck-display-") as temporary: + directory = Path(temporary) + write_stubs(directory) + (directory / "main.cpp").write_text(MAIN) + binary = directory / "driver" + result = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", *SANITIZER_FLAGS, "-DLILYGO_TDECK", "-I"+str(directory), + "-I"+str(ROOT / "src"), str(directory / "main.cpp"), + str(ROOT / "src/helpers/ui/ST7789LCDDisplay.cpp"), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout+result.stderr) + for scenario in ("buffered", "allocation-failure"): + with self.subTest(scenario=scenario): + result = subprocess.run([str(binary), scenario], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout+result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/lilygo_tdeck/target.cpp b/variants/lilygo_tdeck/target.cpp index d20c56a3e..513f89ba0 100644 --- a/variants/lilygo_tdeck/target.cpp +++ b/variants/lilygo_tdeck/target.cpp @@ -1,11 +1,13 @@ #include #include "target.h" +#include TDeckBoard board; #if defined(P_LORA_SCLK) static SPIClass spi; - RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); + static ESP32BufferedRadioHal radio_hal(spi); + RADIO_CLASS radio = new Module(&radio_hal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); #else RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); #endif @@ -28,10 +30,20 @@ bool radio_init() { Wire.begin(18, 8); #if defined(P_LORA_SCLK) - return radio.std_init(&spi); + const bool ready = radio.std_init(&spi); #else - return radio.std_init(); + const bool ready = radio.std_init(); #endif +#ifdef DISPLAY_CLASS + if (ready) { + // The TFT releases its shared SPI transaction before yielding. Service + // only the radio here: recursively entering the mesh/UI loop is unsafe. + display.setRenderYieldCallback([]() { + if (radio_driver.profiles()->enabled()) radio_driver.loop(); + }); + } +#endif + return ready; } mesh::LocalIdentity radio_new_identity() {