From 29e80cfd657763ee85c32cbe2036c62cd5bbb9cc Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Fri, 24 Jul 2026 00:17:48 +0200 Subject: [PATCH] T-Deck Max & Pro support updates (#582) - Created kernel driver for T-Deck Max & Pro display (needs work, doesn't refresh reliably on T-Deck Pro) - Created T-Deck Pro device implementation (incubating) --- Devices/lilygo-tdeck-max/CMakeLists.txt | 2 +- Devices/lilygo-tdeck-max/device.properties | 13 +- Devices/lilygo-tdeck-max/devicetree.yaml | 1 + Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts | 12 + .../lilygo-tdeck-max/source/Configuration.cpp | 35 - .../source/devices/Display.cpp | 29 - .../lilygo-tdeck-max/source/devices/Display.h | 5 - Devices/lilygo-tdeck-pro/CMakeLists.txt | 7 + Devices/lilygo-tdeck-pro/device.properties | 33 + Devices/lilygo-tdeck-pro/devicetree.yaml | 8 + Devices/lilygo-tdeck-pro/lilygo,tdeck-pro.dts | 138 ++++ Devices/lilygo-tdeck-pro/source/module.cpp | 9 + Drivers/GDEQ031T10/CMakeLists.txt | 5 - Drivers/GDEQ031T10/README.md | 35 - .../GDEQ031T10/Source/Gdeq031t10Display.cpp | 638 ----------------- Drivers/GDEQ031T10/Source/Gdeq031t10Display.h | 225 ------ Drivers/gdeq031t10-module/CMakeLists.txt | 11 + .../gdeq031t10-module/LICENSE-Apache-2.0.md | 195 ++++++ Drivers/gdeq031t10-module/README.md | 11 + .../bindings/gooddisplay,gdeq031t10.yaml | 38 + Drivers/gdeq031t10-module/devicetree.yaml | 3 + .../include/bindings/gdeq031t10.h | 7 + .../include/drivers/gdeq031t10.h | 33 + .../include/gdeq031t10_module.h | 14 + .../gdeq031t10-module/source/gdeq031t10.cpp | 656 ++++++++++++++++++ Drivers/gdeq031t10-module/source/module.cpp | 19 + 26 files changed, 1204 insertions(+), 978 deletions(-) delete mode 100644 Devices/lilygo-tdeck-max/source/Configuration.cpp delete mode 100644 Devices/lilygo-tdeck-max/source/devices/Display.cpp delete mode 100644 Devices/lilygo-tdeck-max/source/devices/Display.h create mode 100644 Devices/lilygo-tdeck-pro/CMakeLists.txt create mode 100644 Devices/lilygo-tdeck-pro/device.properties create mode 100644 Devices/lilygo-tdeck-pro/devicetree.yaml create mode 100644 Devices/lilygo-tdeck-pro/lilygo,tdeck-pro.dts create mode 100644 Devices/lilygo-tdeck-pro/source/module.cpp delete mode 100644 Drivers/GDEQ031T10/CMakeLists.txt delete mode 100644 Drivers/GDEQ031T10/README.md delete mode 100644 Drivers/GDEQ031T10/Source/Gdeq031t10Display.cpp delete mode 100644 Drivers/GDEQ031T10/Source/Gdeq031t10Display.h create mode 100644 Drivers/gdeq031t10-module/CMakeLists.txt create mode 100644 Drivers/gdeq031t10-module/LICENSE-Apache-2.0.md create mode 100644 Drivers/gdeq031t10-module/README.md create mode 100644 Drivers/gdeq031t10-module/bindings/gooddisplay,gdeq031t10.yaml create mode 100644 Drivers/gdeq031t10-module/devicetree.yaml create mode 100644 Drivers/gdeq031t10-module/include/bindings/gdeq031t10.h create mode 100644 Drivers/gdeq031t10-module/include/drivers/gdeq031t10.h create mode 100644 Drivers/gdeq031t10-module/include/gdeq031t10_module.h create mode 100644 Drivers/gdeq031t10-module/source/gdeq031t10.cpp create mode 100644 Drivers/gdeq031t10-module/source/module.cpp diff --git a/Devices/lilygo-tdeck-max/CMakeLists.txt b/Devices/lilygo-tdeck-max/CMakeLists.txt index 6328ca3f..09a2fdee 100644 --- a/Devices/lilygo-tdeck-max/CMakeLists.txt +++ b/Devices/lilygo-tdeck-max/CMakeLists.txt @@ -3,5 +3,5 @@ file(GLOB_RECURSE SOURCE_FILES source/*.c*) idf_component_register( SRCS ${SOURCE_FILES} INCLUDE_DIRS "source" - REQUIRES Tactility GDEQ031T10 driver + REQUIRES TactilityKernel ) diff --git a/Devices/lilygo-tdeck-max/device.properties b/Devices/lilygo-tdeck-max/device.properties index 9fab342d..5d1d0263 100644 --- a/Devices/lilygo-tdeck-max/device.properties +++ b/Devices/lilygo-tdeck-max/device.properties @@ -1,6 +1,6 @@ general.vendor=LilyGO general.name=T-Deck Max -# SD card support is not yet working; remove once it is functional. +# SD card support is not yet working, display driver is untested since refactoring; remove once it is functional. general.incubating=true apps.launcherAppId=Launcher @@ -18,14 +18,17 @@ hardware.bluetooth=true # Internal until the SD card is verified working on this board; switch to SD then. storage.userDataLocation=Internal +dependencies.useDeprecatedHal=false + display.size=3.1" display.shape=rectangle display.dpi=128 -# The GDEQ031T10 e-paper is monochrome, but its driver renders via -# LV_COLOR_FORMAT_I1 on its own display. The global LVGL color depth must stay -# at 16: esp_lvgl_port refuses to compile with LV_COLOR_DEPTH_1 set. -lvgl.colorDepth=16 +lvgl.colorDepth=8 # Monochrome theme: the default colour theme renders accent-coloured UI that # thresholds to invisible on a 1bpp panel (enables CONFIG_LV_USE_THEME_MONO). lvgl.theme=Mono + +cdn.warningMessage=Display not reliably working. Use at your own risk. + +sdkconfig.CONFIG_LV_THEME_DEFAULT_TRANSITION_TIME=0 diff --git a/Devices/lilygo-tdeck-max/devicetree.yaml b/Devices/lilygo-tdeck-max/devicetree.yaml index cf7b192a..ed6d2973 100644 --- a/Devices/lilygo-tdeck-max/devicetree.yaml +++ b/Devices/lilygo-tdeck-max/devicetree.yaml @@ -2,6 +2,7 @@ dependencies: - Platforms/platform-esp32 - Drivers/xl9555-module - Drivers/cst66xx-module + - Drivers/gdeq031t10-module - Drivers/tca8418-module - Drivers/sy6970-module - Drivers/bq27220-module diff --git a/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts b/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts index 46405345..2f0851ab 100644 --- a/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts +++ b/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -143,6 +144,17 @@ // exceeds the default ~4 KB transfer limit. Raise the bus limit to fit it. max-transfer-size = <65536>; + display@0 { + compatible = "gooddisplay,gdeq031t10"; + pin-dc = <&gpio0 35 GPIO_FLAG_NONE>; + pin-reset = <&gpio0 9 GPIO_FLAG_NONE>; + pin-busy = <&gpio0 37 GPIO_FLAG_NONE>; + clock-speed-hz = <10000000>; + // Default to a fast (~1s) refresh for the automatic ghost-clears so the + // full-screen flash they cause is as short as possible. + refresh-mode = ; + }; + sdcard@1 { compatible = "espressif,esp32-sdspi"; status = "disabled"; diff --git a/Devices/lilygo-tdeck-max/source/Configuration.cpp b/Devices/lilygo-tdeck-max/source/Configuration.cpp deleted file mode 100644 index f4f0f0d9..00000000 --- a/Devices/lilygo-tdeck-max/source/Configuration.cpp +++ /dev/null @@ -1,35 +0,0 @@ -#include "devices/Display.h" - -#include -#include -#include -#include - -using namespace tt::hal; - -static bool initBoot() { - // The LoRa and SD chip-selects share the EPD's SPI bus but aren't wired up - // yet. spi0's start() already drives every cs-gpio high during kernel init; - // re-assert deselection before the EPD driver first transacts, as a cheap - // guard against either chip latching stray EPD command bytes (same pattern - // as the esp32_sdspi mount path). - auto* spi0 = device_find_by_name("spi0"); - check(spi0 != nullptr); - esp32_spi_deselect_all_cs(spi0); - - return true; -} - -static DeviceVector createDevices() { - // Touch, keyboard and battery/charging are kernel drivers (cst66xx, tca8418, sy6970, - // bq27220 - see the .dts), started independently of this HAL layer. Only the EPD - // display remains on the deprecated HAL. - return DeviceVector { - createDisplay() - }; -} - -extern const Configuration hardwareConfiguration = { - .initBoot = initBoot, - .createDevices = createDevices -}; diff --git a/Devices/lilygo-tdeck-max/source/devices/Display.cpp b/Devices/lilygo-tdeck-max/source/devices/Display.cpp deleted file mode 100644 index ffc7ba78..00000000 --- a/Devices/lilygo-tdeck-max/source/devices/Display.cpp +++ /dev/null @@ -1,29 +0,0 @@ -#include "Display.h" - -#include - -// Pins from Xinyuan-LilyGO/T-Deck-MAX's lib/TDeckMaxBoard/src/TDeckMaxBoard.h and docs/pinmap.md. -constexpr auto EPD_SPI_HOST = SPI2_HOST; -constexpr auto EPD_PIN_CS = GPIO_NUM_34; -constexpr auto EPD_PIN_DC = GPIO_NUM_35; -constexpr auto EPD_PIN_RST = GPIO_NUM_9; -constexpr auto EPD_PIN_BUSY = GPIO_NUM_37; - -std::shared_ptr createDisplay() { - // Touch is a kernel driver (hynitron,cst66xx - see the .dts), discovered and fed to LVGL - // independently of this display, so no touch device is passed into the configuration here. - auto configuration = std::make_unique( - EPD_SPI_HOST, - EPD_PIN_CS, - EPD_PIN_DC, - EPD_PIN_RST, - EPD_PIN_BUSY, - nullptr, - 10'000'000, - // Default to a fast (~1s) refresh for the automatic ghost-clears so the - // full-screen flash they cause is as short as possible. - Gdeq031t10Display::RefreshMode::Fast - ); - - return std::make_shared(std::move(configuration)); -} diff --git a/Devices/lilygo-tdeck-max/source/devices/Display.h b/Devices/lilygo-tdeck-max/source/devices/Display.h deleted file mode 100644 index 7a9b967d..00000000 --- a/Devices/lilygo-tdeck-max/source/devices/Display.h +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once - -#include - -std::shared_ptr createDisplay(); diff --git a/Devices/lilygo-tdeck-pro/CMakeLists.txt b/Devices/lilygo-tdeck-pro/CMakeLists.txt new file mode 100644 index 00000000..09a2fdee --- /dev/null +++ b/Devices/lilygo-tdeck-pro/CMakeLists.txt @@ -0,0 +1,7 @@ +file(GLOB_RECURSE SOURCE_FILES source/*.c*) + +idf_component_register( + SRCS ${SOURCE_FILES} + INCLUDE_DIRS "source" + REQUIRES TactilityKernel +) diff --git a/Devices/lilygo-tdeck-pro/device.properties b/Devices/lilygo-tdeck-pro/device.properties new file mode 100644 index 00000000..2ffa8f65 --- /dev/null +++ b/Devices/lilygo-tdeck-pro/device.properties @@ -0,0 +1,33 @@ +general.vendor=LilyGO +general.name=T-Deck Pro +# The display driver is not reliable yet +general.incubating=true + +apps.launcherAppId=Launcher + +hardware.target=ESP32S3 +hardware.flashSize=16MB +hardware.flashMode=DIO +hardware.spiRam=true +hardware.spiRamMode=AUTO +hardware.spiRamSpeed=80M +hardware.tinyUsb=true +hardware.esptoolFlashFreq=80M +hardware.bluetooth=true + +storage.userDataLocation=SD + +dependencies.useDeprecatedHal=false + +display.size=3.1" +display.shape=rectangle +display.dpi=128 + +lvgl.colorDepth=8 +# Monochrome theme: the default colour theme renders accent-coloured UI that +# thresholds to invisible on a 1bpp panel (enables CONFIG_LV_USE_THEME_MONO). +lvgl.theme=Mono + +cdn.warningMessage=Only for hardware revision 1.0! Display not reliably working. Use at your own risk. + +sdkconfig.CONFIG_LV_THEME_DEFAULT_TRANSITION_TIME=0 diff --git a/Devices/lilygo-tdeck-pro/devicetree.yaml b/Devices/lilygo-tdeck-pro/devicetree.yaml new file mode 100644 index 00000000..760bb5c1 --- /dev/null +++ b/Devices/lilygo-tdeck-pro/devicetree.yaml @@ -0,0 +1,8 @@ +dependencies: + - Platforms/platform-esp32 + - Drivers/cst328-module + - Drivers/gdeq031t10-module + - Drivers/tca8418-module + - Drivers/sy6970-module + - Drivers/bq25896-module +dts: lilygo,tdeck-pro.dts diff --git a/Devices/lilygo-tdeck-pro/lilygo,tdeck-pro.dts b/Devices/lilygo-tdeck-pro/lilygo,tdeck-pro.dts new file mode 100644 index 00000000..54dd18d5 --- /dev/null +++ b/Devices/lilygo-tdeck-pro/lilygo,tdeck-pro.dts @@ -0,0 +1,138 @@ +/dts-v1/; + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Reference for V1.0 (not V1.1!): https://github.com/Xinyuan-LilyGO/T-Deck-Pro/tree/HD-V1-250326 +// lib/TDeckMaxBoard/src/TDeckMaxBoard.h, docs/pinmap.md +/ { + compatible = "root"; + model = "LilyGO T-Deck Pro"; + + wifi0 { + compatible = "espressif,esp32-wifi-pinned"; + status = "disabled"; + }; + + ble0 { + compatible = "espressif,esp32-ble"; + }; + + gpio0 { + compatible = "espressif,esp32-gpio"; + gpio-count = <49>; + }; + + i2c0 { + compatible = "espressif,esp32-i2c-master"; + port = ; + clock-frequency = <100000>; + pin-sda = <&gpio0 13 GPIO_FLAG_NONE>; + pin-scl = <&gpio0 14 GPIO_FLAG_NONE>; + + touch { + compatible = "hynitron,cst328"; + reg = <0x1A>; + pin-reset = <&gpio0 45 GPIO_FLAG_NONE>; + }; + + // TCA8418 4x10 matrix scanner. Keymaps are written in the vendor's row/column + // orientation (Xinyuan-LilyGO/T-Deck-MAX examples/factory/peri_keypad.cpp); the + // TCA8418 columns are wired in reverse of that order, hence reverse-columns. + // ALT (shift/uppercase) is row 2 col 0; SYM (symbol layer) is row 3 col 8. + keyboard { + compatible = "ti,tca8418"; + reg = <0x34>; + rows = <4>; + columns = <10>; + reverse-columns; + keymap-lc = [ + 113 119 101 114 116 121 117 105 111 112 // q w e r t y u i o p + 97 115 100 102 103 104 106 107 108 8 // a s d f g h j k l BACKSPACE + 0 122 120 99 118 98 110 109 36 10 // z x c v b n m $ ENTER + 0 0 0 0 0 11 48 32 0 9 // PREV 0 SPC NEXT + ]; + keymap-uc = [ + 81 87 69 82 84 89 85 73 79 80 // Q W E R T Y U I O P + 65 83 68 70 71 72 74 75 76 8 // A S D F G H J K L BACKSPACE + 0 90 88 67 86 66 78 77 36 10 // Z X C V B N M $ ENTER + 0 0 0 0 0 11 48 32 0 9 // PREV 0 SPC NEXT + ]; + keymap-sy = [ + 49 50 51 52 53 54 55 56 57 48 // 1 2 3 4 5 6 7 8 9 0 + 64 35 43 45 42 47 40 41 95 8 // @ # + - * / ( ) _ BACKSPACE + 0 33 63 59 58 39 34 44 46 10 // ! ? ; : ' " , . ENTER + 0 0 0 0 0 11 48 32 0 9 // PREV 0 SPC NEXT + ]; + shift-row = <2>; + shift-col = <0>; + sym-row = <3>; + sym-col = <8>; + }; + + bq27220 { + compatible = "ti,bq25896"; + reg = <0x6B>; + }; + }; + + // Keyboard backlight (LED_PWM), GPIO42. The keypress-triggered flash/decay effect + // from the pre-migration driver isn't reproduced here - kernel drivers don't have a + // home for that kind of UI policy (same reasoning as the touch driver's dropped bezel + // keys). Off by default; app code can drive brightness via the generic pwm-backlight + // API if desired. + keyboard_backlight_pwm { + compatible = "espressif,esp32-pwm-ledc"; + pin = <&gpio0 42 GPIO_FLAG_NONE>; + period-ns = <33333>; + ledc-timer = <0>; + ledc-channel = <0>; + }; + + keyboard_backlight { + compatible = "pwm-backlight"; + status = "disabled"; + pwm = <&keyboard_backlight_pwm>; + }; + + spi0 { + compatible = "espressif,esp32-spi"; + host = ; + cs-gpios = <&gpio0 34 GPIO_FLAG_NONE>, // 0: EPD display + <&gpio0 48 GPIO_FLAG_NONE>, // 1: SD card + <&gpio0 3 GPIO_FLAG_NONE>; // 2: LoRa radio (SX1262, not wired up yet) + pin-mosi = <&gpio0 33 GPIO_FLAG_NONE>; + pin-miso = <&gpio0 47 GPIO_FLAG_NONE>; + pin-sclk = <&gpio0 36 GPIO_FLAG_NONE>; + // The EPD pushes its whole 9600-byte framebuffer in one SPI write, which + // exceeds the default ~4 KB transfer limit. Raise the bus limit to fit it. + max-transfer-size = <65536>; + + display@0 { + compatible = "gooddisplay,gdeq031t10"; + pin-dc = <&gpio0 35 GPIO_FLAG_NONE>; + pin-busy = <&gpio0 37 GPIO_FLAG_NONE>; + clock-speed-hz = <10000000>; + // Default to a fast (~1s) refresh for the automatic ghost-clears so the + // full-screen flash they cause is as short as possible. + refresh-mode = ; + }; + + sdcard@1 { + compatible = "espressif,esp32-sdspi"; + status = "disabled"; + frequency-khz = <20000>; + }; + }; +}; diff --git a/Devices/lilygo-tdeck-pro/source/module.cpp b/Devices/lilygo-tdeck-pro/source/module.cpp new file mode 100644 index 00000000..12434d4c --- /dev/null +++ b/Devices/lilygo-tdeck-pro/source/module.cpp @@ -0,0 +1,9 @@ +#include + +extern "C" { + +struct Module lilygo_tdeck_pro_module = { + .name = "lilygo-tdeck-pro" +}; + +} diff --git a/Drivers/GDEQ031T10/CMakeLists.txt b/Drivers/GDEQ031T10/CMakeLists.txt deleted file mode 100644 index f604c373..00000000 --- a/Drivers/GDEQ031T10/CMakeLists.txt +++ /dev/null @@ -1,5 +0,0 @@ -idf_component_register( - SRC_DIRS "Source" - INCLUDE_DIRS "Source" - REQUIRES Tactility driver -) diff --git a/Drivers/GDEQ031T10/README.md b/Drivers/GDEQ031T10/README.md deleted file mode 100644 index 2896e84b..00000000 --- a/Drivers/GDEQ031T10/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# GDEQ031T10 - -Display driver for the GoodDisplay GDEQ031T10, a 3.1" 320x240 SPI e-paper panel -(UC8253-family controller) used by the LilyGO T-Deck Pro/Max. - -The command sequence (panel setting, power on/off, fast/partial LUT timings, deep -sleep) is ported from the vendor reference driver in -[Xinyuan-LilyGO/T-Deck-MAX](https://github.com/Xinyuan-LilyGO/T-Deck-MAX) -(`examples/Elink_paper/GDEQ031T10_Arduino/Display_EPD_W21.cpp`). - -Unlike `EPDiyDisplay` (which targets parallel-bus "EPD47"-style panels via the -`epdiy` library), this panel uses a simple 4-wire SPI interface (CS/DC/RST/BUSY + -SCK/MOSI), so it's driven directly with ESP-IDF's `spi_master` and `gpio` APIs -rather than `epdiy` or `esp_lcd_panel`. - -Each LVGL flush is staged and handed off to a dedicated refresh task (the physical -refresh takes hundreds of ms to over a second, too long to run in LVGL's flush -callback without stalling input). The task diffs the new frame against a shadow of -what the panel currently holds and, where possible, performs a windowed partial -refresh of just the changed region instead of redrawing the whole panel. A full -refresh is still done on the first draw, on an explicit `requestFullRefresh()`, -when a change covers most of the screen, or periodically to clear the ghosting -that partial updates accumulate (gated by `MAX_PARTIAL_REFRESHES`, with a -localized ghost-clear preferred over a full flash when the accumulated changes are -confined to a small region). The panel's charge pump is powered off once the -refresh task's queue goes idle, not after every single refresh. Four refresh -modes are available, trading speed for quality: - -- `Full` (~3s) - best quality, used by default -- `Fast` (~1.0s) -- `Slow` (~1.5s) -- `Partial` (~0.5s) - most ghosting - -`requestFullRefresh()` forces the next flush to use `Full` mode, useful for -clearing up ghosting accumulated from a run of fast/partial refreshes. diff --git a/Drivers/GDEQ031T10/Source/Gdeq031t10Display.cpp b/Drivers/GDEQ031T10/Source/Gdeq031t10Display.cpp deleted file mode 100644 index e900101e..00000000 --- a/Drivers/GDEQ031T10/Source/Gdeq031t10Display.cpp +++ /dev/null @@ -1,638 +0,0 @@ -#include "Gdeq031t10Display.h" - -#include - -#include -#include -#include -#include -#include -// Full lv_theme_t definition (opaque in public lvgl.h) — needed to extend the -// mono theme with a custom apply callback, per LVGL's theme-extension pattern. -#include - -constexpr auto* TAG = "GDEQ031T10"; - -// UC8253-family commands, ported from Xinyuan-LilyGO/T-Deck-MAX's -// Display_EPD_W21.cpp reference driver. -namespace { -constexpr uint8_t CMD_PANEL_SETTING = 0x00; -constexpr uint8_t CMD_POWER_ON_OFF = 0x02; // shared opcode: power on when followed by 0x04, power off as standalone 0x02 -constexpr uint8_t CMD_POWER_ON = 0x04; -constexpr uint8_t CMD_DEEP_SLEEP = 0x07; -constexpr uint8_t CMD_DATA_START_OLD = 0x10; -constexpr uint8_t CMD_DISPLAY_REFRESH = 0x12; -constexpr uint8_t CMD_DATA_START_NEW = 0x13; -constexpr uint8_t CMD_VCOM_DATA_INTERVAL = 0x50; -constexpr uint8_t CMD_PARTIAL_WINDOW = 0x90; -constexpr uint8_t CMD_PARTIAL_IN = 0x91; -constexpr uint8_t CMD_PARTIAL_OUT = 0x92; -constexpr uint8_t CMD_FAST_MODE_ENABLE = 0xE0; -constexpr uint8_t CMD_FAST_MODE_TIMING = 0xE5; - -constexpr uint8_t DEEP_SLEEP_CHECK_CODE = 0xA5; -} - -bool Gdeq031t10Display::writeCommand(uint8_t command) { - gpio_set_level(configuration->pinDc, 0); - spi_transaction_t transaction = {}; - transaction.length = 8; - transaction.tx_buffer = &command; - if (spi_device_polling_transmit(spiDevice, &transaction) != ESP_OK) { - LOG_E(TAG, "SPI command transfer failed"); - return false; - } - return true; -} - -bool Gdeq031t10Display::writeData(const uint8_t* data, size_t length) { - gpio_set_level(configuration->pinDc, 1); - spi_transaction_t transaction = {}; - transaction.length = length * 8; - transaction.tx_buffer = data; - if (spi_device_polling_transmit(spiDevice, &transaction) != ESP_OK) { - LOG_E(TAG, "SPI data transfer failed"); - return false; - } - return true; -} - -bool Gdeq031t10Display::waitWhileBusy() const { - // BUSY pin reads high when the controller is idle/ready. - constexpr TickType_t timeout = pdMS_TO_TICKS(5000); - const TickType_t start = xTaskGetTickCount(); - while (gpio_get_level(configuration->pinBusy) != 1) { - if (xTaskGetTickCount() - start > timeout) { - LOG_E(TAG, "Timed out waiting for panel BUSY"); - return false; - } - vTaskDelay(pdMS_TO_TICKS(2)); - } - return true; -} - -void Gdeq031t10Display::reset() const { - gpio_set_level(configuration->pinReset, 0); - vTaskDelay(pdMS_TO_TICKS(10)); - gpio_set_level(configuration->pinReset, 1); - vTaskDelay(pdMS_TO_TICKS(10)); -} - -bool Gdeq031t10Display::initFull() { - reset(); - bool ok = writeCommand(CMD_PANEL_SETTING); - ok = ok && writeData(configuration->mirror180 ? 0x13 : 0x1F); - ok = ok && writeCommand(CMD_POWER_ON); - ok = ok && waitWhileBusy(); - if (!ok) { - LOG_E(TAG, "Full init failed"); - return false; - } - panelMode = RefreshMode::Full; - panelPowerOn = true; - return true; -} - -bool Gdeq031t10Display::initFast() { - if (!initFull()) { - return false; - } - bool ok = writeCommand(CMD_FAST_MODE_ENABLE); - ok = ok && writeData(0x02); - ok = ok && writeCommand(CMD_FAST_MODE_TIMING); - ok = ok && writeData(0x5A); // ~1.0s - if (!ok) { - LOG_E(TAG, "Fast mode init failed"); - return false; - } - panelMode = RefreshMode::Fast; - return true; -} - -bool Gdeq031t10Display::initSlow() { - if (!initFull()) { - return false; - } - bool ok = writeCommand(CMD_FAST_MODE_ENABLE); - ok = ok && writeData(0x02); - ok = ok && writeCommand(CMD_FAST_MODE_TIMING); - ok = ok && writeData(0x6E); // ~1.5s - if (!ok) { - LOG_E(TAG, "Slow mode init failed"); - return false; - } - panelMode = RefreshMode::Slow; - return true; -} - -bool Gdeq031t10Display::initPartial() { - if (!initFull()) { - return false; - } - bool ok = writeCommand(CMD_FAST_MODE_ENABLE); - ok = ok && writeData(0x02); - ok = ok && writeCommand(CMD_FAST_MODE_TIMING); - ok = ok && writeData(0x79); - ok = ok && writeCommand(CMD_VCOM_DATA_INTERVAL); - ok = ok && writeData(0xD7); - if (!ok) { - LOG_E(TAG, "Partial mode init failed"); - return false; - } - panelMode = RefreshMode::Partial; - return true; -} - -bool Gdeq031t10Display::ensurePanelReady(RefreshMode mode) { - if (panelMode != mode) { - // A mode change needs the full init path: it starts with reset(), which - // restores register defaults (required when leaving partial mode, whose - // VCOM/data-interval setting would otherwise linger). - switch (mode) { - case RefreshMode::Full: return initFull(); - case RefreshMode::Fast: return initFast(); - case RefreshMode::Slow: return initSlow(); - case RefreshMode::Partial: return initPartial(); - } - return false; - } else if (!panelPowerOn) { - // Registers still hold the mode; only the charge pump was idled. - if (!writeCommand(CMD_POWER_ON) || !waitWhileBusy()) { - LOG_E(TAG, "Panel did not become ready after power-on"); - return false; - } - panelPowerOn = true; - } - return true; -} - -bool Gdeq031t10Display::powerOff() { - // Command the panel off regardless of whether BUSY confirms it: retrying - // forever here would just as likely hang, and a stuck-BUSY panel is - // already unusable either way. - bool ok = writeCommand(CMD_POWER_ON_OFF); // 0x02 standalone = power off - if (!ok || !waitWhileBusy()) { - LOG_E(TAG, "Panel did not confirm power-off"); - ok = false; - } - panelPowerOn = false; - return ok; -} - -void Gdeq031t10Display::queueRefresh() { - xSemaphoreTake(bufferMutex, portMAX_DELAY); - // renderFramebuffer always holds a complete frame (single buffer, full - // render mode), so the staged copy is self-contained. - std::memcpy(pendingFramebuffer.get(), renderFramebuffer.get() + LVGL_I1_PALETTE_SIZE, FRAMEBUFFER_SIZE); - framePending = true; - xSemaphoreGive(bufferMutex); - xTaskNotifyGive(refreshTask); -} - -void Gdeq031t10Display::refreshTaskMain(void* parameter) { - auto* self = static_cast(parameter); - self->runRefreshTask(); - xSemaphoreGive(self->refreshTaskExited); - vTaskDelete(nullptr); -} - -void Gdeq031t10Display::runRefreshTask() { - while (true) { - ulTaskNotifyTake(pdTRUE, portMAX_DELAY); - if (refreshTaskShouldExit) { - return; - } - - // Drain staged frames latest-wins: everything LVGL flushed while the - // previous refresh was in progress collapses into one panel update. - // A frame staged while the panel is off stays staged; the power-on - // path kicks this task to draw it. - while (!refreshTaskShouldExit && powered) { - xSemaphoreTake(bufferMutex, portMAX_DELAY); - const bool havePending = framePending; - if (havePending) { - std::swap(pendingFramebuffer, taskFramebuffer); - framePending = false; - } - xSemaphoreGive(bufferMutex); - - if (!havePending) { - break; - } - refresh(); - } - - // The pipeline went idle: drop the charge pump now rather than on a - // timer. The mode registers survive a power-off, so the next refresh - // only pays a short power-on wait (on this task, invisible to the UI). - xSemaphoreTake(panelMutex, portMAX_DELAY); - if (initialized && powered && panelPowerOn) { - powerOff(); - } - xSemaphoreGive(panelMutex); - } -} - -void Gdeq031t10Display::refresh() { - const uint8_t* renderBitmap = taskFramebuffer.get(); - constexpr int BYTES_PER_ROW = WIDTH / 8; - - // Find the bounding box of bytes that differ from what the panel holds. The - // panel stores the inverted (shadow) polarity, so compare against ~render. - int firstCol = BYTES_PER_ROW, lastCol = -1, firstRow = HEIGHT, lastRow = -1; - for (int row = 0; row < HEIGHT; row++) { - const size_t base = static_cast(row) * BYTES_PER_ROW; - for (int col = 0; col < BYTES_PER_ROW; col++) { - if (static_cast(~renderBitmap[base + col]) != shadowFramebuffer[base + col]) { - if (col < firstCol) firstCol = col; - if (col > lastCol) lastCol = col; - if (row < firstRow) firstRow = row; - if (row > lastRow) lastRow = row; - } - } - } - - const bool nothingChanged = (lastCol < 0); - if (nothingChanged && !forceFullRefresh) { - return; // panel already shows this frame; don't refresh needlessly - } - - // A change covering most of the screen or an explicit request warrants a - // full refresh; otherwise refresh just the box. - const int changedRows = nothingChanged ? 0 : (lastRow - firstRow + 1); - const bool largeChange = changedRows > (HEIGHT * 3 / 4); - - xSemaphoreTake(panelMutex, portMAX_DELAY); - if (!powered || !initialized) { - // The display was turned off/stopped while this frame was staged; the - // shadow mismatch makes the frame redraw after the next power-on. - xSemaphoreGive(panelMutex); - return; - } - if (forceFullRefresh || largeChange || nothingChanged) { - // Explicit requests get the best-quality LUT; automatic ghost-clears use - // the configured (typically faster) mode. - refreshFull(forceFullRefresh ? RefreshMode::Full : currentRefreshMode.load()); - forceFullRefresh = false; - partialRefreshCount = 0; - ghostAreaValid = false; - xSemaphoreGive(panelMutex); - return; - } - - // Grow the ghost union with this change so the gate sees where partial - // updates have accumulated since the last clear. - if (!ghostAreaValid) { - ghostFirstCol = firstCol; - ghostLastCol = lastCol; - ghostFirstRow = firstRow; - ghostLastRow = lastRow; - ghostAreaValid = true; - } else { - ghostFirstCol = std::min(ghostFirstCol, firstCol); - ghostLastCol = std::max(ghostLastCol, lastCol); - ghostFirstRow = std::min(ghostFirstRow, firstRow); - ghostLastRow = std::max(ghostLastRow, lastRow); - } - - if (partialRefreshCount >= MAX_PARTIAL_REFRESHES) { - // Ghost-clear gate. If the accumulated churn is confined to a small - // region (a blinking cursor, a line being typed into), scrub just that - // window — a full-screen flash there is needless and distracting. Only - // widespread churn earns a whole-panel refresh. - const int unionWidth = (ghostLastCol - ghostFirstCol + 1) * 8; - const int unionHeight = ghostLastRow - ghostFirstRow + 1; - const bool unionIsLarge = unionWidth * unionHeight * 4 >= WIDTH * HEIGHT; // >= 25% of the panel - if (unionIsLarge) { - refreshFull(currentRefreshMode); - } else { - refreshWindow(ghostFirstCol, ghostLastCol, ghostFirstRow, ghostLastRow, true); - } - partialRefreshCount = 0; - ghostAreaValid = false; - } else { - refreshWindow(firstCol, lastCol, firstRow, lastRow, false); - partialRefreshCount++; - } - xSemaphoreGive(panelMutex); -} - -void Gdeq031t10Display::refreshFull(RefreshMode mode) { - if (!ensurePanelReady(mode)) { - LOG_E(TAG, "Skipping full refresh: panel not ready"); - return; - } - - const uint8_t* renderBitmap = taskFramebuffer.get(); - - // shadowFramebuffer keeps the panel-polarity copy of the last frame for the - // controller's old/new differential refresh; LVGL's I1 polarity is inverted. - if (!writeCommand(CMD_DATA_START_OLD) || !writeData(shadowFramebuffer.get(), FRAMEBUFFER_SIZE)) { - LOG_E(TAG, "Failed to send old frame data"); - return; - } - - for (size_t i = 0; i < FRAMEBUFFER_SIZE; i++) { - shadowFramebuffer[i] = static_cast(~renderBitmap[i]); - } - if (!writeCommand(CMD_DATA_START_NEW) || !writeData(shadowFramebuffer.get(), FRAMEBUFFER_SIZE)) { - LOG_E(TAG, "Failed to send new frame data"); - return; - } - - if (!writeCommand(CMD_DISPLAY_REFRESH)) { - LOG_E(TAG, "Failed to trigger display refresh"); - return; - } - vTaskDelay(pdMS_TO_TICKS(1)); // datasheet requires >=200us settle before polling BUSY - if (!waitWhileBusy()) { - LOG_E(TAG, "Full refresh did not complete"); - } -} - -void Gdeq031t10Display::refreshWindow(int firstByteCol, int lastByteCol, int firstRow, int lastRow, bool scrubGhosts) { - // Partial LUT (fast waveform via temperature force) on a sub-region only. The - // RAM window must be byte-aligned in X, which it already is (byte columns). - if (!ensurePanelReady(RefreshMode::Partial)) { - LOG_E(TAG, "Skipping window refresh: panel not ready"); - return; - } - - const uint8_t* renderBitmap = taskFramebuffer.get(); - constexpr int BYTES_PER_ROW = WIDTH / 8; - const int widthBytes = lastByteCol - firstByteCol + 1; - - const uint16_t x = static_cast(firstByteCol * 8); - const uint16_t xe = static_cast(lastByteCol * 8 + 7); - const uint16_t y = static_cast(firstRow); - const uint16_t ye = static_cast(lastRow); - - // Set the partial RAM window (GxEPD2 GDEQ031T10 sequence). - bool ok = writeCommand(CMD_PARTIAL_IN); - ok = ok && writeCommand(CMD_PARTIAL_WINDOW); - ok = ok && writeData(static_cast(x)); - ok = ok && writeData(static_cast(xe)); - ok = ok && writeData(static_cast(y >> 8)); - ok = ok && writeData(static_cast(y & 0xFF)); - ok = ok && writeData(static_cast(ye >> 8)); - ok = ok && writeData(static_cast(ye & 0xFF)); - ok = ok && writeData(0x01); - if (!ok) { - LOG_E(TAG, "Failed to set partial refresh window"); - writeCommand(CMD_PARTIAL_OUT); // best-effort: leave partial-window mode - return; - } - - // Old region: normally the region's current panel contents (shadow), - // gathered contiguously. For a ghost scrub, feed the complement of the new - // data instead: the controller then sees every pixel as changed and drives - // each one through a transition, clearing accumulated partial-refresh - // ghosting in this window only. - size_t n = 0; - for (int row = firstRow; row <= lastRow; row++) { - const size_t base = static_cast(row) * BYTES_PER_ROW + firstByteCol; - if (scrubGhosts) { - // New panel data is ~renderBitmap, so its complement is renderBitmap. - std::memcpy(®ionBuffer[n], &renderBitmap[base], widthBytes); - } else { - std::memcpy(®ionBuffer[n], &shadowFramebuffer[base], widthBytes); - } - n += widthBytes; - } - if (!writeCommand(CMD_DATA_START_OLD) || !writeData(regionBuffer.get(), n)) { - LOG_E(TAG, "Failed to send old window data"); - writeCommand(CMD_PARTIAL_OUT); - return; - } - - // New region: inverted render, and update the shadow for this region as we go. - n = 0; - for (int row = firstRow; row <= lastRow; row++) { - const size_t base = static_cast(row) * BYTES_PER_ROW + firstByteCol; - for (int c = 0; c < widthBytes; c++) { - const uint8_t value = static_cast(~renderBitmap[base + c]); - regionBuffer[n++] = value; - shadowFramebuffer[base + c] = value; - } - } - if (!writeCommand(CMD_DATA_START_NEW) || !writeData(regionBuffer.get(), n)) { - LOG_E(TAG, "Failed to send new window data"); - writeCommand(CMD_PARTIAL_OUT); - return; - } - - if (writeCommand(CMD_DISPLAY_REFRESH)) { - vTaskDelay(pdMS_TO_TICKS(1)); - if (!waitWhileBusy()) { - LOG_E(TAG, "Window refresh did not complete"); - } - } else { - LOG_E(TAG, "Failed to trigger window refresh"); - } - writeCommand(CMD_PARTIAL_OUT); -} - -void Gdeq031t10Display::flushCallback(lv_display_t* display, const lv_area_t* area, uint8_t* pixelMap) { - // pixelMap points into renderFramebuffer (it was passed directly to - // lv_display_set_buffers). Only stage the frame here: the physical refresh - // takes up to ~1s, so it runs on the driver's refresh task instead of - // blocking the LVGL task (which would stall all input handling). - auto* self = static_cast(lv_display_get_user_data(display)); - - if (lv_display_flush_is_last(display)) { - self->queueRefresh(); - } - - lv_display_flush_ready(display); -} - -void Gdeq031t10Display::themeApplyCallback(lv_theme_t* /*theme*/, lv_obj_t* obj) { - // Keep textarea cursors solid (no blink): on e-paper each blink toggle is a - // panel refresh. anim_duration 0 makes lv_textarea skip the blink animation. - if (lv_obj_check_type(obj, &lv_textarea_class)) { - lv_obj_set_style_anim_duration(obj, 0, LV_PART_CURSOR); - } -} - -bool Gdeq031t10Display::start() { - if (initialized) { - return true; - } - - gpio_config_t outputConfig = { - .pin_bit_mask = (1ULL << configuration->pinDc) | (1ULL << configuration->pinReset), - .mode = GPIO_MODE_OUTPUT, - .pull_up_en = GPIO_PULLUP_DISABLE, - .pull_down_en = GPIO_PULLDOWN_DISABLE, - .intr_type = GPIO_INTR_DISABLE, - }; - gpio_config(&outputConfig); - - gpio_config_t busyConfig = { - .pin_bit_mask = 1ULL << configuration->pinBusy, - .mode = GPIO_MODE_INPUT, - .pull_up_en = GPIO_PULLUP_DISABLE, - .pull_down_en = GPIO_PULLDOWN_DISABLE, - .intr_type = GPIO_INTR_DISABLE, - }; - gpio_config(&busyConfig); - - spi_device_interface_config_t deviceConfig = { - .mode = 0, - .clock_speed_hz = configuration->clockSpeedHz, - .spics_io_num = configuration->pinCs, - .queue_size = 1, - }; - - if (spi_bus_add_device(configuration->spiHost, &deviceConfig, &spiDevice) != ESP_OK) { - LOG_E(TAG, "Failed to add SPI device"); - return false; - } - - if (panelMutex == nullptr) { - panelMutex = xSemaphoreCreateMutex(); - } - if (bufferMutex == nullptr) { - bufferMutex = xSemaphoreCreateMutex(); - } - if (refreshTaskExited == nullptr) { - refreshTaskExited = xSemaphoreCreateBinary(); - } - - shadowFramebuffer = std::make_unique(FRAMEBUFFER_SIZE); - // LVGL needs room for the 8-byte I1 palette ahead of the 1bpp bitmap. - renderFramebuffer = std::make_unique(LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE); - // Scratch for gathering a windowed region (worst case = the whole frame). - regionBuffer = std::make_unique(FRAMEBUFFER_SIZE); - pendingFramebuffer = std::make_unique(FRAMEBUFFER_SIZE); - taskFramebuffer = std::make_unique(FRAMEBUFFER_SIZE); - std::memset(shadowFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE); - std::memset(renderFramebuffer.get(), 0xFF, LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE); - std::memset(pendingFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE); - std::memset(taskFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE); - - currentRefreshMode = configuration->defaultRefreshMode; - initFull(); - - refreshTaskShouldExit = false; - if (xTaskCreate(&refreshTaskMain, "epd_refresh", REFRESH_TASK_STACK_SIZE, this, REFRESH_TASK_PRIORITY, &refreshTask) != pdPASS) { - LOG_E(TAG, "Failed to create refresh task"); - spi_bus_remove_device(spiDevice); - spiDevice = nullptr; - shadowFramebuffer.reset(); - renderFramebuffer.reset(); - regionBuffer.reset(); - pendingFramebuffer.reset(); - taskFramebuffer.reset(); - return false; - } - powered = true; - initialized = true; - return true; -} - -bool Gdeq031t10Display::stop() { - if (!initialized) { - return true; - } - - stopLvgl(); - - // Join the refresh task before touching the panel: it may be mid-refresh. - refreshTaskShouldExit = true; - xTaskNotifyGive(refreshTask); - xSemaphoreTake(refreshTaskExited, portMAX_DELAY); - refreshTask = nullptr; - - setPowerOn(false); - - xSemaphoreTake(panelMutex, portMAX_DELAY); - initialized = false; - spi_bus_remove_device(spiDevice); - spiDevice = nullptr; - shadowFramebuffer.reset(); - renderFramebuffer.reset(); - regionBuffer.reset(); - pendingFramebuffer.reset(); - taskFramebuffer.reset(); - framePending = false; - xSemaphoreGive(panelMutex); - return true; -} - -void Gdeq031t10Display::setPowerOn(bool turnOn) { - if (turnOn == powered) { - return; - } - - xSemaphoreTake(panelMutex, portMAX_DELAY); - if (turnOn) { - initFull(); // toggling RST also wakes the panel from deep sleep - } else { - if (panelPowerOn) { - powerOff(); - } - vTaskDelay(pdMS_TO_TICKS(100)); - writeCommand(CMD_DEEP_SLEEP); - writeData(DEEP_SLEEP_CHECK_CODE); - // Deep sleep needs a reset to wake, which restores register defaults. - panelMode.reset(); - } - - powered = turnOn; - xSemaphoreGive(panelMutex); - - if (turnOn && refreshTask != nullptr) { - // initFull left the charge pump on. Kick the refresh task: it redraws - // any frame staged while the panel was off, then idles the pump down. - xTaskNotifyGive(refreshTask); - } -} - -void Gdeq031t10Display::requestFullRefresh() { - forceFullRefresh = true; -} - -bool Gdeq031t10Display::startLvgl() { - if (lvglDisplay != nullptr) { - return true; - } - - lvglDisplay = lv_display_create(Gdeq031t10Display::WIDTH, Gdeq031t10Display::HEIGHT); - if (lvglDisplay == nullptr) { - return false; - } - - lv_display_set_user_data(lvglDisplay, this); - lv_display_set_color_format(lvglDisplay, LV_COLOR_FORMAT_I1); - - // The default colour theme renders accent-coloured text/icons that threshold - // to near-invisible on a 1bpp panel. Apply LVGL's monochrome theme (light - // background, dark foreground) so UI content shows as solid black on white. - lv_theme_t* baseTheme = lv_theme_mono_init(lvglDisplay, false, LV_FONT_DEFAULT); - // Chain a theme on top of the mono theme that disables the textarea cursor - // blink. A blinking cursor invalidates its region ~twice a second, and on - // e-paper every invalidation is a panel refresh, so text-entry screens (e.g. - // the Wi-Fi password field) flash continuously. The mono theme still applies - // first (it's the parent); themeApplyCallback only pins the cursor solid. - static lv_theme_t epaperTheme; - epaperTheme = *baseTheme; - lv_theme_set_parent(&epaperTheme, baseTheme); - lv_theme_set_apply_cb(&epaperTheme, &Gdeq031t10Display::themeApplyCallback); - lv_display_set_theme(lvglDisplay, &epaperTheme); - lv_display_set_render_mode(lvglDisplay, LV_DISPLAY_RENDER_MODE_FULL); - lv_display_set_buffers(lvglDisplay, renderFramebuffer.get(), nullptr, LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE, LV_DISPLAY_RENDER_MODE_FULL); - lv_display_set_flush_cb(lvglDisplay, flushCallback); - - return true; -} - -bool Gdeq031t10Display::stopLvgl() { - if (lvglDisplay == nullptr) { - return true; - } - - lv_display_delete(lvglDisplay); - lvglDisplay = nullptr; - return true; -} diff --git a/Drivers/GDEQ031T10/Source/Gdeq031t10Display.h b/Drivers/GDEQ031T10/Source/Gdeq031t10Display.h deleted file mode 100644 index 691aa1ce..00000000 --- a/Drivers/GDEQ031T10/Source/Gdeq031t10Display.h +++ /dev/null @@ -1,225 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -/** - * Driver for the GoodDisplay GDEQ031T10 (UC8253-family controller) 3.1" 320x240 - * SPI e-paper panel, as used on the LilyGO T-Deck Pro/Max. - * - * Command sequence and timings are ported from the vendor reference driver in - * Xinyuan-LilyGO/T-Deck-MAX (examples/Elink_paper/GDEQ031T10_Arduino). - */ -class Gdeq031t10Display final : public tt::hal::display::DisplayDevice { - -public: - - enum class RefreshMode { - Full, // ~3s, best quality - Fast, // ~1.0s - Slow, // ~1.5s, fast LUT with extra settling - Partial // ~0.5s, full-frame partial-mode refresh (more ghosting) - }; - - class Configuration { - public: - - Configuration( - spi_host_device_t spiHost, - gpio_num_t pinCs, - gpio_num_t pinDc, - gpio_num_t pinReset, - gpio_num_t pinBusy, - std::shared_ptr touch = nullptr, - int clockSpeedHz = 4'000'000, - RefreshMode defaultRefreshMode = RefreshMode::Full, - bool mirror180 = false - ) : spiHost(spiHost), - pinCs(pinCs), - pinDc(pinDc), - pinReset(pinReset), - pinBusy(pinBusy), - touch(std::move(touch)), - clockSpeedHz(clockSpeedHz), - defaultRefreshMode(defaultRefreshMode), - mirror180(mirror180) - {} - - spi_host_device_t spiHost; - gpio_num_t pinCs; - gpio_num_t pinDc; - gpio_num_t pinReset; - gpio_num_t pinBusy; - std::shared_ptr touch; - int clockSpeedHz; - RefreshMode defaultRefreshMode; - /** Panel is mounted upside down relative to the reference orientation */ - bool mirror180; - }; - - static constexpr uint16_t WIDTH = 240; - static constexpr uint16_t HEIGHT = 320; - static constexpr size_t FRAMEBUFFER_SIZE = (WIDTH * HEIGHT) / 8; // 1 bpp packed - // LVGL 9 stores a 2-colour palette (2 x lv_color32_t = 8 bytes) at the start - // of an LV_COLOR_FORMAT_I1 buffer, before the packed 1bpp bitmap. - static constexpr size_t LVGL_I1_PALETTE_SIZE = 8; - -private: - - std::unique_ptr configuration; - spi_device_handle_t spiDevice = nullptr; - lv_display_t* _Nullable lvglDisplay = nullptr; - /** Mirrors what the panel currently holds, required by the controller's - * "old data" + "new data" double-buffered refresh protocol. */ - std::unique_ptr shadowFramebuffer; - /** Render target for LVGL; copied to the panel on flush */ - std::unique_ptr renderFramebuffer; - bool initialized = false; - std::atomic powered = false; - std::atomic currentRefreshMode = RefreshMode::Full; - /** Number of windowed partial refreshes since the last full refresh. A full - * refresh is forced once this reaches MAX_PARTIAL_REFRESHES to clear the - * ghosting that partial updates accumulate. */ - uint8_t partialRefreshCount = 0; - /** Forces the next refresh to be a full-screen refresh (set at boot and by - * requestFullRefresh, read on the refresh task). */ - std::atomic forceFullRefresh = true; - /** Scratch buffer used to gather a windowed region's bytes for one SPI write. */ - std::unique_ptr regionBuffer; - static constexpr uint8_t MAX_PARTIAL_REFRESHES = 8; - /** Union bounding box (byte-column/row space) of all windowed partial - * refreshes since the last ghost-clear. When the partial-refresh gate - * expires, this decides between a localized scrub and a full-screen - * refresh: small repeated updates (a text cursor, a typing line) shouldn't - * flash the whole panel. */ - bool ghostAreaValid = false; - int ghostFirstCol = 0; - int ghostLastCol = 0; - int ghostFirstRow = 0; - int ghostLastRow = 0; - /** Waveform mode the panel registers currently hold. Empty when the - * registers are in an unknown state (before first init, or after deep sleep, - * which requires a reset that restores defaults). */ - std::optional panelMode; - /** True while the panel's charge pump is on (CMD_POWER_ON issued, no - * power-off since). */ - bool panelPowerOn = false; - /** Serializes panel/SPI access between the refresh task and external - * callers (setPowerOn, stop). */ - SemaphoreHandle_t panelMutex = nullptr; - - // The physical refresh takes hundreds of ms to over a second of ink - // movement. Running it inside LVGL's flush callback would block the LVGL - // task (and with it all input handling) for that long, so flushes only - // stage the frame and a dedicated task drives the panel. Frames coalesce - // latest-wins: however many flushes arrive during a refresh, only the - // newest staged frame is drawn next. - static constexpr uint32_t REFRESH_TASK_PRIORITY = 3; // below LVGL (6), above idle - static constexpr uint32_t REFRESH_TASK_STACK_SIZE = 4096; - TaskHandle_t refreshTask = nullptr; - /** Signalled by the refresh task right before it exits; stop() joins on it. */ - SemaphoreHandle_t refreshTaskExited = nullptr; - /** Guards pendingFramebuffer and framePending (LVGL flush vs refresh task). */ - SemaphoreHandle_t bufferMutex = nullptr; - /** Latest complete frame staged by the LVGL flush callback. */ - std::unique_ptr pendingFramebuffer; - /** The refresh task's working copy of the frame it is drawing (swapped with - * pendingFramebuffer under bufferMutex; no copy on the task side). */ - std::unique_ptr taskFramebuffer; - bool framePending = false; - std::atomic refreshTaskShouldExit = false; - - /** Returns false if the SPI transfer failed; the panel/shadow state should - * not be advanced as if it succeeded. */ - bool writeCommand(uint8_t command); - bool writeData(const uint8_t* data, size_t length); - bool writeData(uint8_t data) { return writeData(&data, 1); } - /** Waits for the BUSY pin to report ready, up to a fixed timeout. Returns - * false (and logs) if the panel never became ready, e.g. bad wiring or no - * panel attached — callers must not assume the operation completed. */ - bool waitWhileBusy() const; - void reset() const; - - bool initFull(); - bool initFast(); - bool initSlow(); - bool initPartial(); - - /** Puts the panel in the given waveform mode with the charge pump on, - * skipping the (expensive) init sequence when it already is. */ - bool ensurePanelReady(RefreshMode mode); - - bool powerOff(); - /** Stages the LVGL-rendered frame for the refresh task (called on flush). */ - void queueRefresh(); - static void refreshTaskMain(void* parameter); - void runRefreshTask(); - void refresh(); - void refreshFull(RefreshMode mode); - /** Windowed partial refresh. With scrubGhosts the controller is fed "old" - * data that is the complement of the new frame, so it drives every pixel in - * the window through a transition — a localized ghost-clear that only - * flashes the window itself. */ - void refreshWindow(int firstByteCol, int lastByteCol, int firstRow, int lastRow, bool scrubGhosts); - - static void flushCallback(lv_display_t* display, const lv_area_t* area, uint8_t* pixelMap); - /** Theme hook: disables the textarea cursor blink (see startLvgl). */ - static void themeApplyCallback(lv_theme_t* theme, lv_obj_t* obj); - -public: - - explicit Gdeq031t10Display(std::unique_ptr inConfiguration) : configuration(std::move(inConfiguration)) { - assert(configuration != nullptr); - } - - ~Gdeq031t10Display() override { - if (panelMutex != nullptr) { - vSemaphoreDelete(panelMutex); - } - if (bufferMutex != nullptr) { - vSemaphoreDelete(bufferMutex); - } - if (refreshTaskExited != nullptr) { - vSemaphoreDelete(refreshTaskExited); - } - } - - std::string getName() const override { return "GDEQ031T10"; } - - std::string getDescription() const override { return "GoodDisplay GDEQ031T10 e-paper display"; } - - bool start() override; - bool stop() override; - - void setPowerOn(bool turnOn) override; - bool isPoweredOn() const override { return powered; } - bool supportsPowerControl() const override { return true; } - - void requestFullRefresh() override; - - std::shared_ptr _Nullable getTouchDevice() override { - return configuration->touch; - } - - bool supportsLvgl() const override { return true; } - bool startLvgl() override; - bool stopLvgl() override; - lv_display_t* _Nullable getLvglDisplay() const override { return lvglDisplay; } - - bool supportsDisplayDriver() const override { return false; } - std::shared_ptr _Nullable getDisplayDriver() override { return nullptr; } - - /** Sets the refresh mode used for automatic (non-full) refreshes from now on, - * until changed again by a subsequent call. */ - void setRefreshMode(RefreshMode mode) { currentRefreshMode = mode; } -}; diff --git a/Drivers/gdeq031t10-module/CMakeLists.txt b/Drivers/gdeq031t10-module/CMakeLists.txt new file mode 100644 index 00000000..5656edce --- /dev/null +++ b/Drivers/gdeq031t10-module/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.20) + +include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake") + +file(GLOB_RECURSE SOURCE_FILES "source/*.c*") + +tactility_add_module(gdeq031t10-module + SRCS ${SOURCE_FILES} + INCLUDE_DIRS include/ + REQUIRES TactilityKernel platform-esp32 driver +) diff --git a/Drivers/gdeq031t10-module/LICENSE-Apache-2.0.md b/Drivers/gdeq031t10-module/LICENSE-Apache-2.0.md new file mode 100644 index 00000000..f5f4b8b5 --- /dev/null +++ b/Drivers/gdeq031t10-module/LICENSE-Apache-2.0.md @@ -0,0 +1,195 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/Drivers/gdeq031t10-module/README.md b/Drivers/gdeq031t10-module/README.md new file mode 100644 index 00000000..37f32078 --- /dev/null +++ b/Drivers/gdeq031t10-module/README.md @@ -0,0 +1,11 @@ +# GDEQ031T10 Display Driver + +A kernel driver for the GoodDisplay `GDEQ031T10`, a 3.1" 240x320 SPI e-paper panel +(UC8253-family controller) used by the LilyGO T-Deck Pro/Max. + +The command sequence (panel setting, power on/off, fast/partial LUT timings, deep +sleep) is ported from the vendor reference driver in +[Xinyuan-LilyGO/T-Deck-MAX](https://github.com/Xinyuan-LilyGO/T-Deck-MAX) +(`examples/Elink_paper/GDEQ031T10_Arduino/Display_EPD_W21.cpp`). + +License: [Apache v2.0](LICENSE-Apache-2.0.md) diff --git a/Drivers/gdeq031t10-module/bindings/gooddisplay,gdeq031t10.yaml b/Drivers/gdeq031t10-module/bindings/gooddisplay,gdeq031t10.yaml new file mode 100644 index 00000000..4d5fd5d7 --- /dev/null +++ b/Drivers/gdeq031t10-module/bindings/gooddisplay,gdeq031t10.yaml @@ -0,0 +1,38 @@ +description: > + GoodDisplay GDEQ031T10 (UC8253-family controller) 3.1" 320x240 SPI e-paper panel, + as used on the LilyGO T-Deck Pro/Max. Monochrome (1bpp), full-frame refreshes with + automatic windowed partial updates and ghost-clearing. + +compatible: "gooddisplay,gdeq031t10" + +bus: spi + +properties: + pin-dc: + type: phandles + required: true + description: Data/Command GPIO pin + pin-reset: + type: phandles + required: false + default: GPIO_PIN_SPEC_NONE + description: Reset GPIO pin + pin-busy: + type: phandles + required: true + description: Busy GPIO pin (reads high when the controller is idle/ready) + clock-speed-hz: + type: int + default: 10000000 + description: SPI clock frequency in Hz + refresh-mode: + type: int + default: GDEQ031T10_REFRESH_FAST + description: > + Gdeq031t10RefreshMode waveform used for automatic full-screen refreshes + (e.g. ghost-clears). Explicit full-refresh requests always use the + best-quality full waveform. + mirror-180: + type: boolean + default: false + description: Panel is mounted upside down relative to the reference orientation diff --git a/Drivers/gdeq031t10-module/devicetree.yaml b/Drivers/gdeq031t10-module/devicetree.yaml new file mode 100644 index 00000000..a07d6f33 --- /dev/null +++ b/Drivers/gdeq031t10-module/devicetree.yaml @@ -0,0 +1,3 @@ +dependencies: + - TactilityKernel +bindings: bindings diff --git a/Drivers/gdeq031t10-module/include/bindings/gdeq031t10.h b/Drivers/gdeq031t10-module/include/bindings/gdeq031t10.h new file mode 100644 index 00000000..1129eaf7 --- /dev/null +++ b/Drivers/gdeq031t10-module/include/bindings/gdeq031t10.h @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +DEFINE_DEVICETREE(gdeq031t10, struct Gdeq031t10Config) diff --git a/Drivers/gdeq031t10-module/include/drivers/gdeq031t10.h b/Drivers/gdeq031t10-module/include/drivers/gdeq031t10.h new file mode 100644 index 00000000..dbd95b56 --- /dev/null +++ b/Drivers/gdeq031t10-module/include/drivers/gdeq031t10.h @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +#include + +/** Waveform/refresh mode used for automatic full-screen refreshes. */ +enum Gdeq031t10RefreshMode { + GDEQ031T10_REFRESH_FULL = 0, // ~3s, best quality + GDEQ031T10_REFRESH_FAST = 1, // ~1.0s + GDEQ031T10_REFRESH_SLOW = 2, // ~1.5s, fast LUT with extra settling + GDEQ031T10_REFRESH_PARTIAL = 3 // ~0.5s, full-frame partial-mode refresh (more ghosting) +}; + +struct Gdeq031t10Config { + struct GpioPinSpec pin_dc; + struct GpioPinSpec pin_reset; + struct GpioPinSpec pin_busy; + int clock_speed_hz; + enum Gdeq031t10RefreshMode refresh_mode; + /** Panel is mounted upside down relative to the reference orientation */ + bool mirror_180; +}; + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/gdeq031t10-module/include/gdeq031t10_module.h b/Drivers/gdeq031t10-module/include/gdeq031t10_module.h new file mode 100644 index 00000000..9610926f --- /dev/null +++ b/Drivers/gdeq031t10-module/include/gdeq031t10_module.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +extern struct Module gdeq031t10_module; + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/gdeq031t10-module/source/gdeq031t10.cpp b/Drivers/gdeq031t10-module/source/gdeq031t10.cpp new file mode 100644 index 00000000..e797cbf1 --- /dev/null +++ b/Drivers/gdeq031t10-module/source/gdeq031t10.cpp @@ -0,0 +1,656 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include + +constexpr auto* TAG = "GDEQ031T10"; +#define GET_CONFIG(device) (static_cast((device)->config)) + +static constexpr int WIDTH = 240; +static constexpr int HEIGHT = 320; +static constexpr size_t FRAMEBUFFER_SIZE = (WIDTH * HEIGHT) / 8; // 1 bpp packed +static constexpr int BYTES_PER_ROW = WIDTH / 8; + +// UC8253-family commands, ported from Xinyuan-LilyGO/T-Deck-MAX's +// Display_EPD_W21.cpp reference driver. +static constexpr uint8_t CMD_PANEL_SETTING = 0x00; +static constexpr uint8_t CMD_POWER_ON_OFF = 0x02; // shared opcode: power on when followed by 0x04, power off as standalone 0x02 +static constexpr uint8_t CMD_POWER_ON = 0x04; +static constexpr uint8_t CMD_DEEP_SLEEP = 0x07; +static constexpr uint8_t CMD_DATA_START_OLD = 0x10; +static constexpr uint8_t CMD_DISPLAY_REFRESH = 0x12; +static constexpr uint8_t CMD_DATA_START_NEW = 0x13; +static constexpr uint8_t CMD_VCOM_DATA_INTERVAL = 0x50; +static constexpr uint8_t CMD_PARTIAL_WINDOW = 0x90; +static constexpr uint8_t CMD_PARTIAL_IN = 0x91; +static constexpr uint8_t CMD_PARTIAL_OUT = 0x92; +static constexpr uint8_t CMD_FAST_MODE_ENABLE = 0xE0; +static constexpr uint8_t CMD_FAST_MODE_TIMING = 0xE5; + +static constexpr uint8_t DEEP_SLEEP_CHECK_CODE = 0xA5; + +extern "C" { + +struct Gdeq031t10Internal { + spi_device_handle_t spi_device; + struct GpioDescriptor* dc; + struct GpioDescriptor* reset; // optional + struct GpioDescriptor* busy; + /** Mirrors what the panel currently holds, required by the controller's + * "old data" + "new data" double-buffered refresh protocol. Panel polarity + * matches the LVGL 1bpp render data directly (bit 1 = white, bit 0 = black). */ + uint8_t* shadow_framebuffer; + /** Scratch buffer used to gather a windowed region's bytes for one SPI write. */ + uint8_t* region_buffer; + /** Serializes panel/SPI access between draw_bitmap and power on/off calls. */ + SemaphoreHandle_t panel_mutex; + /** Waveform mode the panel registers currently hold; valid=false when the + * registers are in an unknown state (before first init, or after deep sleep, + * which requires a reset that restores defaults). */ + enum Gdeq031t10RefreshMode panel_mode; + bool panel_mode_valid; + /** True while the panel's charge pump is on (CMD_POWER_ON issued, no power-off since). */ + bool panel_power_on; + /** disp_on_off state; the panel is in deep sleep while false. */ + bool display_on; + /** Forces the next refresh to be a full-screen refresh (set at boot and by reset()). */ + bool force_full_refresh; +}; + +// region Panel protocol + +static bool write_command(Gdeq031t10Internal* internal, uint8_t command) { + gpio_descriptor_set_level(internal->dc, false); + spi_transaction_t transaction = {}; + transaction.length = 8; + transaction.tx_buffer = &command; + if (spi_device_polling_transmit(internal->spi_device, &transaction) != ESP_OK) { + LOG_E(TAG, "SPI command transfer failed"); + return false; + } + return true; +} + +static bool write_data(Gdeq031t10Internal* internal, const uint8_t* data, size_t length) { + gpio_descriptor_set_level(internal->dc, true); + spi_transaction_t transaction = {}; + transaction.length = length * 8; + transaction.tx_buffer = data; + if (spi_device_polling_transmit(internal->spi_device, &transaction) != ESP_OK) { + LOG_E(TAG, "SPI data transfer failed"); + return false; + } + return true; +} + +static bool write_data_byte(Gdeq031t10Internal* internal, uint8_t data) { + return write_data(internal, &data, 1); +} + +static bool wait_while_busy(Gdeq031t10Internal* internal) { + // gpio_descriptor_get_level() reports the panel's busy state directly (true = busy). + const TickType_t timeout = pdMS_TO_TICKS(5000); + const TickType_t start = get_ticks(); + bool busy = true; + while (gpio_descriptor_get_level(internal->busy, &busy) == ERROR_NONE && busy) { + if (get_ticks() - start > timeout) { + LOG_E(TAG, "Timed out waiting for panel BUSY"); + return false; + } + delay_millis(2); + } + LOG_I(TAG, "waited %lu ms until not busy", get_ticks() - start); + return !busy; +} + +static void hardware_reset(Gdeq031t10Internal* internal) { + if (internal->reset != nullptr) { + gpio_descriptor_set_level(internal->reset, false); + delay_millis(10); + gpio_descriptor_set_level(internal->reset, true); + delay_millis(10); + } +} + +static bool init_full(Gdeq031t10Internal* internal, bool mirror_180) { + bool ok = write_command(internal, CMD_PANEL_SETTING); + ok = ok && write_data_byte(internal, mirror_180 ? 0x13 : 0x1F); + ok = ok && write_command(internal, CMD_POWER_ON); + ok = ok && wait_while_busy(internal); + if (!ok) { + LOG_E(TAG, "Full init failed"); + return false; + } + internal->panel_mode = GDEQ031T10_REFRESH_FULL; + internal->panel_mode_valid = true; + internal->panel_power_on = true; + return true; +} + +static bool init_with_fast_lut(Gdeq031t10Internal* internal, bool mirror_180, enum Gdeq031t10RefreshMode mode) { + if (!init_full(internal, mirror_180)) { + return false; + } + // Fast-LUT timing values from the vendor reference driver: ~1.0s (fast), + // ~1.5s (slow, extra settling) and the partial-mode value. + uint8_t timing; + switch (mode) { + case GDEQ031T10_REFRESH_FAST: timing = 0x5A; break; + case GDEQ031T10_REFRESH_SLOW: timing = 0x6E; break; + case GDEQ031T10_REFRESH_PARTIAL: timing = 0x79; break; + default: return true; // GDEQ031T10_REFRESH_FULL: init_full() already did everything + } + bool ok = write_command(internal, CMD_FAST_MODE_ENABLE); + ok = ok && write_data_byte(internal, 0x02); + ok = ok && write_command(internal, CMD_FAST_MODE_TIMING); + ok = ok && write_data_byte(internal, timing); + if (ok && mode == GDEQ031T10_REFRESH_PARTIAL) { + ok = write_command(internal, CMD_VCOM_DATA_INTERVAL); + ok = ok && write_data_byte(internal, 0xD7); + } + if (!ok) { + LOG_E(TAG, "Mode init failed"); + return false; + } + internal->panel_mode = mode; + return true; +} + +static bool ensure_panel_ready(Gdeq031t10Internal* internal, bool mirror_180, enum Gdeq031t10RefreshMode mode) { + if (!internal->panel_mode_valid || internal->panel_mode != mode) { + return init_with_fast_lut(internal, mirror_180, mode); + } else if (!internal->panel_power_on) { + // Registers still hold the mode; only the charge pump was idled. + if (!write_command(internal, CMD_POWER_ON) || !wait_while_busy(internal)) { + LOG_E(TAG, "Panel did not become ready after power-on"); + return false; + } + internal->panel_power_on = true; + } + return true; +} + +static bool panel_power_off(Gdeq031t10Internal* internal) { + // Command the panel off regardless of whether BUSY confirms it: retrying + // forever here would just as likely hang, and a stuck-BUSY panel is + // already unusable either way. + bool ok = write_command(internal, CMD_POWER_ON_OFF); // 0x02 standalone = power off + if (!ok || !wait_while_busy(internal)) { + LOG_E(TAG, "Panel did not confirm power-off"); + ok = false; + } + internal->panel_power_on = false; + return ok; +} + +static void refresh_full(Gdeq031t10Internal* internal, bool mirror_180, enum Gdeq031t10RefreshMode mode, const uint8_t* render_bitmap) { + // Always do a full register reload before this update, and put the panel back into + // deep sleep after it, instead of caching/reusing state across updates the way + // ensure_panel_ready() does when the mode hasn't changed. This matches the vendor reference + // driver (Xinyuan-LilyGO/T-Deck-MAX's Display_EPD_W21.cpp) exactly: it calls + // EPD_Init()/EPD_Init_Fast() before every single update and EPD_DeepSleep() after every + // single update, never reusing controller state between them. Reusing state - or tracking + // real old-data without also doing this, or vice versa - each independently left the panel + // showing an intermittent/alternating blank screen; only the combination matches what the + // panel's internal state machine tolerates. + if (!init_with_fast_lut(internal, mirror_180, mode)) { + LOG_E(TAG, "Skipping full refresh: panel not ready"); + return; + } + + // shadow_framebuffer holds the panel's actual current content (matches the vendor's tracked + // oldData[] buffer) - send it as "old" data so the controller computes correct per-pixel + // transitions. + LOG_I(TAG, "write old data from shadow_buffer"); + if (!write_command(internal, CMD_DATA_START_OLD) || !write_data(internal, internal->shadow_framebuffer, FRAMEBUFFER_SIZE)) { + LOG_E(TAG, "Failed to send old frame data"); + return; + } + + LOG_I(TAG, "memcpy render_bitmap -> shadow_buffer"); + std::memcpy(internal->shadow_framebuffer, render_bitmap, FRAMEBUFFER_SIZE); + if (!write_command(internal, CMD_DATA_START_NEW) || !write_data(internal, internal->shadow_framebuffer, FRAMEBUFFER_SIZE)) { + LOG_E(TAG, "Failed to send new frame data"); + return; + } + + if (!write_command(internal, CMD_DISPLAY_REFRESH)) { + LOG_E(TAG, "Failed to trigger display refresh"); + return; + } + delay_millis(1); // datasheet requires >=200us settle before polling BUSY + if (!wait_while_busy(internal)) { + LOG_E(TAG, "Full refresh did not complete"); + } + + // EPD_DeepSleep(): power off, then actually deep-sleep rather than just idling the charge + // pump. panel_mode_valid=false forces the next refresh_full() call back through a fresh + // init above instead of reusing (possibly stale) controller state. + if (internal->panel_power_on) { + panel_power_off(internal); + } +} + +/** Windowed partial refresh of the given byte-column/row bounding box. */ +static void refresh_window(Gdeq031t10Internal* internal, bool mirror_180, const uint8_t* render_bitmap, int first_byte_col, int last_byte_col, int first_row, int last_row) { + // Partial LUT (fast waveform via temperature force) on a sub-region only. The + // RAM window must be byte-aligned in X, which it already is (byte columns). + if (!ensure_panel_ready(internal, mirror_180, GDEQ031T10_REFRESH_PARTIAL)) { + LOG_E(TAG, "Skipping window refresh: panel not ready"); + return; + } + + const int width_bytes = last_byte_col - first_byte_col + 1; + + const uint16_t x = static_cast(first_byte_col * 8); + const uint16_t xe = static_cast(last_byte_col * 8 + 7); + const uint16_t y = static_cast(first_row); + const uint16_t ye = static_cast(last_row); + + // Set the partial RAM window (GxEPD2 GDEQ031T10 sequence). + bool ok = write_command(internal, CMD_PARTIAL_IN); + ok = ok && write_command(internal, CMD_PARTIAL_WINDOW); + ok = ok && write_data_byte(internal, static_cast(x)); + ok = ok && write_data_byte(internal, static_cast(xe)); + ok = ok && write_data_byte(internal, static_cast(y >> 8)); + ok = ok && write_data_byte(internal, static_cast(y & 0xFF)); + ok = ok && write_data_byte(internal, static_cast(ye >> 8)); + ok = ok && write_data_byte(internal, static_cast(ye & 0xFF)); + ok = ok && write_data_byte(internal, 0x01); + if (!ok) { + LOG_E(TAG, "Failed to set partial refresh window"); + write_command(internal, CMD_PARTIAL_OUT); // best-effort: leave partial-window mode + return; + } + + // Old region: the region's current panel contents (shadow), gathered contiguously. + size_t n = 0; + for (int row = first_row; row <= last_row; row++) { + const size_t base = static_cast(row) * BYTES_PER_ROW + first_byte_col; + std::memcpy(&internal->region_buffer[n], &internal->shadow_framebuffer[base], width_bytes); + n += width_bytes; + } + if (!write_command(internal, CMD_DATA_START_OLD) || !write_data(internal, internal->region_buffer, n)) { + LOG_E(TAG, "Failed to send old window data"); + write_command(internal, CMD_PARTIAL_OUT); + return; + } + + // New region: render data, and update the shadow for this region as we go. + n = 0; + for (int row = first_row; row <= last_row; row++) { + const size_t base = static_cast(row) * BYTES_PER_ROW + first_byte_col; + for (int c = 0; c < width_bytes; c++) { + const uint8_t value = render_bitmap[base + c]; + internal->region_buffer[n++] = value; + internal->shadow_framebuffer[base + c] = value; + } + } + if (!write_command(internal, CMD_DATA_START_NEW) || !write_data(internal, internal->region_buffer, n)) { + LOG_E(TAG, "Failed to send new window data"); + write_command(internal, CMD_PARTIAL_OUT); + return; + } + + if (write_command(internal, CMD_DISPLAY_REFRESH)) { + delay_millis(1); + if (!wait_while_busy(internal)) { + LOG_E(TAG, "Window refresh did not complete"); + } + } else { + LOG_E(TAG, "Failed to trigger window refresh"); + } + write_command(internal, CMD_PARTIAL_OUT); +} + +// endregion + +// region DisplayApi + +static error_t gdeq031t10_reset(Device* device) { + auto* internal = static_cast(device_get_driver_data(device)); + xSemaphoreTake(internal->panel_mutex, portMAX_DELAY); + // The reset pulse restores register defaults and drops the charge pump. + hardware_reset(internal); + internal->panel_mode_valid = false; + internal->panel_power_on = false; + internal->force_full_refresh = true; + xSemaphoreGive(internal->panel_mutex); + return ERROR_NONE; +} + +static error_t gdeq031t10_init(Device* device) { + auto* internal = static_cast(device_get_driver_data(device)); + const auto* config = GET_CONFIG(device); + xSemaphoreTake(internal->panel_mutex, portMAX_DELAY); + bool ok = init_full(internal, config->mirror_180); + xSemaphoreGive(internal->panel_mutex); + return ok ? ERROR_NONE : ERROR_RESOURCE; +} + +// LVGL only ever calls this with the full frame: DISPLAY_COLOR_FORMAT_MONOCHROME forces +// LV_DISPLAY_RENDER_MODE_FULL in the generic kernel LVGL bridge (lvgl_display.c), and FULL mode +// only presents (calls draw_bitmap) once per render cycle, with the complete 0,0..hres,vres rect. +// color_data is row-major, MSB-first 1bpp (LVGL's LV_COLOR_FORMAT_I1 with the palette header +// already stripped by the caller); bit 1 = white, bit 0 = black. The panel expects the same +// polarity, so color_data is written to the panel unmodified. +static error_t gdeq031t10_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) { + auto* internal = static_cast(device_get_driver_data(device)); + const auto* config = GET_CONFIG(device); + + LOG_I(TAG, "draw_bitmap %d %d - %d %d", x_start, y_start, x_end, y_end); + if (x_start != 0 || y_start != 0 || x_end != WIDTH || y_end != HEIGHT) { + LOG_I(TAG, "draw_bitmap: Only full-frame draws are supported (got %ld,%ld..%ld,%ld)", (long)x_start, (long)y_start, (long)x_end, (long)y_end); + return ERROR_NOT_SUPPORTED; + } + + const auto* render_bitmap = static_cast(color_data); + + xSemaphoreTake(internal->panel_mutex, portMAX_DELAY); + + // Work-around until partial updates are working + internal->force_full_refresh = true; + + if (!internal->display_on) { + // Display is off (deep sleep). Drop the frame without touching the shadow: the + // mismatch it leaves behind makes the frame redraw after the next power-on. + xSemaphoreGive(internal->panel_mutex); + LOG_W(TAG, "draw_bitmap: ignoring drawing, display is off"); + return ERROR_NONE; + } + + // Find the bounding box of bytes that differ from what the panel holds (shadow + // holds the same polarity as render_bitmap). + int first_col = BYTES_PER_ROW, last_col = -1, first_row = HEIGHT, last_row = -1; + for (int row = 0; row < HEIGHT; row++) { + const size_t base = static_cast(row) * BYTES_PER_ROW; + for (int col = 0; col < BYTES_PER_ROW; col++) { + if (render_bitmap[base + col] != internal->shadow_framebuffer[base + col]) { + if (col < first_col) first_col = col; + if (col > last_col) last_col = col; + if (row < first_row) first_row = row; + if (row > last_row) last_row = row; + } + } + } + + const bool nothing_changed = (last_col < 0); + if (nothing_changed && !internal->force_full_refresh) { + xSemaphoreGive(internal->panel_mutex); + return ERROR_NONE; // panel already shows this frame; don't refresh needlessly + } + + if (internal->force_full_refresh) { + LOG_I(TAG, "draw_bitmap: refresh_full"); + refresh_full(internal, config->mirror_180, config->refresh_mode, render_bitmap); + internal->force_full_refresh = false; + } else { + LOG_I(TAG, "draw_bitmap: refresh_window"); + refresh_window(internal, config->mirror_180, render_bitmap, first_col, last_col, first_row, last_row); + } + + // The refresh is synchronous, so the pipeline is idle here: drop the charge pump rather + // than leave it running. The mode registers survive a power-off, so the next refresh + // only pays a short power-on wait. + if (internal->panel_power_on) { + panel_power_off(internal); + } + + xSemaphoreGive(internal->panel_mutex); + return ERROR_NONE; +} + +static error_t gdeq031t10_disp_on_off(Device* device, bool on_off) { + auto* internal = static_cast(device_get_driver_data(device)); + const auto* config = GET_CONFIG(device); + + xSemaphoreTake(internal->panel_mutex, portMAX_DELAY); + if (on_off == internal->display_on) { + xSemaphoreGive(internal->panel_mutex); + return ERROR_NONE; + } + + bool ok = true; + if (on_off) { + // Toggling RST (in init_full) also wakes the panel from deep sleep. The panel kept + // its image through deep sleep and the shadow still matches it, so no forced + // refresh is needed: any frame dropped while off left a shadow mismatch that the + // next draw picks up. + ok = init_full(internal, config->mirror_180); + if (ok && internal->panel_power_on) { + // init_full left the charge pump on; idle it until the next draw needs it. + panel_power_off(internal); + } + } else { + if (internal->panel_power_on) { + panel_power_off(internal); + } + delay_millis(100); + write_command(internal, CMD_DEEP_SLEEP); + write_data_byte(internal, DEEP_SLEEP_CHECK_CODE); + // Deep sleep needs a reset to wake, which restores register defaults. + internal->panel_mode_valid = false; + } + + internal->display_on = on_off; + xSemaphoreGive(internal->panel_mutex); + return ok ? ERROR_NONE : ERROR_RESOURCE; +} + +static DisplayColorFormat gdeq031t10_get_color_format(Device*) { + return DISPLAY_COLOR_FORMAT_MONOCHROME; +} + +static uint16_t gdeq031t10_get_resolution_x(Device*) { + return WIDTH; +} + +static uint16_t gdeq031t10_get_resolution_y(Device*) { + return HEIGHT; +} + +static void gdeq031t10_get_frame_buffer(Device*, uint8_t, void** out_buffer) { + *out_buffer = nullptr; +} + +static uint8_t gdeq031t10_get_frame_buffer_count(Device*) { + return 0; +} + +// endregion + +static const DisplayApi gdeq031t10_display_api = { + .capabilities = DISPLAY_CAPABILITY_ON_OFF | DISPLAY_CAPABILITY_SLOW_REFRESH, + .reset = gdeq031t10_reset, + .init = gdeq031t10_init, + .draw_bitmap = gdeq031t10_draw_bitmap, + .mirror = nullptr, + .swap_xy = nullptr, + .get_swap_xy = nullptr, + .get_mirror_x = nullptr, + .get_mirror_y = nullptr, + .set_gap = nullptr, + .get_gap_x = nullptr, + .get_gap_y = nullptr, + .invert_color = nullptr, + .disp_on_off = gdeq031t10_disp_on_off, + .disp_sleep = nullptr, + .get_color_format = gdeq031t10_get_color_format, + .get_resolution_x = gdeq031t10_get_resolution_x, + .get_resolution_y = gdeq031t10_get_resolution_y, + .get_frame_buffer = gdeq031t10_get_frame_buffer, + .get_frame_buffer_count = gdeq031t10_get_frame_buffer_count, + .get_backlight = nullptr, + .has_capability = nullptr, +}; + +// region Driver lifecycle + +static void free_internal(Gdeq031t10Internal* internal) { + if (internal->spi_device != nullptr) { + spi_bus_remove_device(internal->spi_device); + } + if (internal->dc != nullptr) { + gpio_descriptor_release(internal->dc); + } + if (internal->reset != nullptr) { + gpio_descriptor_release(internal->reset); + } + if (internal->busy != nullptr) { + gpio_descriptor_release(internal->busy); + } + if (internal->panel_mutex != nullptr) { + vSemaphoreDelete(internal->panel_mutex); + } + free(internal->shadow_framebuffer); + free(internal->region_buffer); + free(internal); +} + +static error_t start(Device* device) { + auto* parent = device_get_parent(device); + check(device_get_type(parent) == &SPI_CONTROLLER_TYPE); + + const auto* spi_config = static_cast(parent->config); + const auto* config = GET_CONFIG(device); + + struct GpioPinSpec cs_pin; + if (esp32_spi_get_cs_pin(device, &cs_pin) != ERROR_NONE) { + LOG_E(TAG, "Failed to resolve CS pin"); + return ERROR_RESOURCE; + } + + auto* internal = static_cast(calloc(1, sizeof(Gdeq031t10Internal))); + if (internal == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + + internal->dc = gpio_descriptor_acquire( + config->pin_dc.gpio_controller, + config->pin_dc.pin, + config->pin_dc.flags | GPIO_FLAG_DIRECTION_OUTPUT, + GPIO_OWNER_GPIO + ); + + if (config->pin_reset.gpio_controller != nullptr) { + internal->reset = gpio_descriptor_acquire( + config->pin_reset.gpio_controller, + config->pin_reset.pin, + config->pin_reset.flags | GPIO_FLAG_DIRECTION_OUTPUT, + GPIO_OWNER_GPIO + ); + } else { + internal->reset = nullptr; + } + + internal->busy = gpio_descriptor_acquire( + config->pin_busy.gpio_controller, + config->pin_busy.pin, + config->pin_busy.flags | GPIO_FLAG_DIRECTION_INPUT | GPIO_FLAG_ACTIVE_LOW, + GPIO_OWNER_GPIO + ); + + internal->panel_mutex = xSemaphoreCreateMutex(); + internal->shadow_framebuffer = static_cast(malloc(FRAMEBUFFER_SIZE)); + internal->region_buffer = static_cast(malloc(FRAMEBUFFER_SIZE)); + if ( + internal->dc == nullptr || + internal->busy == nullptr || + internal->panel_mutex == nullptr || + internal->shadow_framebuffer == nullptr || + internal->region_buffer == nullptr + // Note: reset pin is not checked as it is optional + ) { + LOG_E(TAG, "Failed to acquire GPIO pins or allocate buffers"); + free_internal(internal); + return ERROR_OUT_OF_MEMORY; + } + + gpio_descriptor_set_flags(internal->dc, GPIO_FLAG_DIRECTION_OUTPUT); + gpio_descriptor_set_flags(internal->busy, GPIO_FLAG_DIRECTION_INPUT); + if (internal->reset != nullptr) { + gpio_descriptor_set_flags(internal->reset, GPIO_FLAG_DIRECTION_OUTPUT); + } + + spi_device_interface_config_t device_config = {}; + device_config.mode = 0; + device_config.clock_speed_hz = config->clock_speed_hz; + device_config.spics_io_num = cs_pin.gpio_controller == nullptr ? -1 : static_cast(cs_pin.pin); + device_config.queue_size = 1; + + if (spi_bus_add_device(spi_config->host, &device_config, &internal->spi_device) != ESP_OK) { + LOG_E(TAG, "Failed to add SPI device"); + free_internal(internal); + return ERROR_RESOURCE; + } + + // 0xFF matches an all-white render (bit 1 = white), consistent with the panel's blank + // power-up state and with the forced first full refresh below. + std::memset(internal->shadow_framebuffer, 0xFF, FRAMEBUFFER_SIZE); + + if (!init_full(internal, config->mirror_180)) { + free_internal(internal); + return ERROR_RESOURCE; + } + + internal->display_on = true; + internal->force_full_refresh = true; + + device_set_driver_data(device, internal); + return ERROR_NONE; +} + +static error_t stop(Device* device) { + auto* internal = static_cast(device_get_driver_data(device)); + + xSemaphoreTake(internal->panel_mutex, portMAX_DELAY); + if (internal->display_on) { + // Same sequence as disp_on_off(false): leave the panel in deep sleep. + if (internal->panel_power_on) { + panel_power_off(internal); + } + delay_millis(100); + write_command(internal, CMD_DEEP_SLEEP); + write_data_byte(internal, DEEP_SLEEP_CHECK_CODE); + internal->display_on = false; + } + xSemaphoreGive(internal->panel_mutex); + + free_internal(internal); + device_set_driver_data(device, nullptr); + return ERROR_NONE; +} + +// endregion + +Driver gdeq031t10_driver = { + .name = "gdeq031t10", + .compatible = (const char*[]) { "gooddisplay,gdeq031t10", nullptr }, + .start_device = start, + .stop_device = stop, + .api = &gdeq031t10_display_api, + .device_type = &DISPLAY_TYPE, + .owner = &gdeq031t10_module, + .internal = nullptr +}; + +} \ No newline at end of file diff --git a/Drivers/gdeq031t10-module/source/module.cpp b/Drivers/gdeq031t10-module/source/module.cpp new file mode 100644 index 00000000..be30cbd3 --- /dev/null +++ b/Drivers/gdeq031t10-module/source/module.cpp @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +extern Driver gdeq031t10_driver; + +extern "C" { + +static Driver* const gdeq031t10_drivers[] = { + &gdeq031t10_driver, + nullptr +}; + +Module gdeq031t10_module = { + .name = "gdeq031t10", + .drivers = gdeq031t10_drivers +}; + +} // extern "C"