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() {