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)
This commit is contained in:
Ken Van Hoeylandt
2026-07-24 00:17:48 +02:00
committed by GitHub
parent 429125734a
commit 29e80cfd65
26 changed files with 1204 additions and 978 deletions
+1 -1
View File
@@ -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
)
+8 -5
View File
@@ -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
+1
View File
@@ -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
@@ -11,6 +11,7 @@
#include <tactility/bindings/pwm_backlight.h>
#include <bindings/xl9555.h>
#include <bindings/cst66xx.h>
#include <bindings/gdeq031t10.h>
#include <bindings/tca8418.h>
#include <bindings/sy6970.h>
#include <bindings/bq27220.h>
@@ -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 = <GDEQ031T10_REFRESH_FAST>;
};
sdcard@1 {
compatible = "espressif,esp32-sdspi";
status = "disabled";
@@ -1,35 +0,0 @@
#include "devices/Display.h"
#include <Tactility/hal/Configuration.h>
#include <tactility/check.h>
#include <tactility/device.h>
#include <tactility/drivers/esp32_spi.h>
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
};
@@ -1,29 +0,0 @@
#include "Display.h"
#include <Gdeq031t10Display.h>
// 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<tt::hal::display::DisplayDevice> 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<Gdeq031t10Display::Configuration>(
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<Gdeq031t10Display>(std::move(configuration));
}
@@ -1,5 +0,0 @@
#pragma once
#include <Tactility/hal/display/DisplayDevice.h>
std::shared_ptr<tt::hal::display::DisplayDevice> createDisplay();
+7
View File
@@ -0,0 +1,7 @@
file(GLOB_RECURSE SOURCE_FILES source/*.c*)
idf_component_register(
SRCS ${SOURCE_FILES}
INCLUDE_DIRS "source"
REQUIRES TactilityKernel
)
@@ -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
+8
View File
@@ -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
@@ -0,0 +1,138 @@
/dts-v1/;
#include <tactility/bindings/root.h>
#include <tactility/bindings/esp32_ble.h>
#include <tactility/bindings/esp32_wifi_pinned.h>
#include <tactility/bindings/esp32_gpio.h>
#include <tactility/bindings/esp32_i2c_master.h>
#include <tactility/bindings/esp32_spi.h>
#include <tactility/bindings/esp32_sdspi.h>
#include <tactility/bindings/esp32_pwm_ledc.h>
#include <tactility/bindings/pwm_backlight.h>
#include <bindings/cst328.h>
#include <bindings/gdeq031t10.h>
#include <bindings/tca8418.h>
#include <bindings/bq25896.h>
// 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 = <I2C_NUM_0>;
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 = <SPI2_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 = <GDEQ031T10_REFRESH_FAST>;
};
sdcard@1 {
compatible = "espressif,esp32-sdspi";
status = "disabled";
frequency-khz = <20000>;
};
};
};
@@ -0,0 +1,9 @@
#include <tactility/module.h>
extern "C" {
struct Module lilygo_tdeck_pro_module = {
.name = "lilygo-tdeck-pro"
};
}
-5
View File
@@ -1,5 +0,0 @@
idf_component_register(
SRC_DIRS "Source"
INCLUDE_DIRS "Source"
REQUIRES Tactility driver
)
-35
View File
@@ -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.
@@ -1,638 +0,0 @@
#include "Gdeq031t10Display.h"
#include <tactility/log.h>
#include <algorithm>
#include <cstring>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <themes/mono/lv_theme_mono.h>
// 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 <themes/lv_theme_private.h>
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<Gdeq031t10Display*>(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<size_t>(row) * BYTES_PER_ROW;
for (int col = 0; col < BYTES_PER_ROW; col++) {
if (static_cast<uint8_t>(~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<uint8_t>(~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<uint16_t>(firstByteCol * 8);
const uint16_t xe = static_cast<uint16_t>(lastByteCol * 8 + 7);
const uint16_t y = static_cast<uint16_t>(firstRow);
const uint16_t ye = static_cast<uint16_t>(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<uint8_t>(x));
ok = ok && writeData(static_cast<uint8_t>(xe));
ok = ok && writeData(static_cast<uint8_t>(y >> 8));
ok = ok && writeData(static_cast<uint8_t>(y & 0xFF));
ok = ok && writeData(static_cast<uint8_t>(ye >> 8));
ok = ok && writeData(static_cast<uint8_t>(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<size_t>(row) * BYTES_PER_ROW + firstByteCol;
if (scrubGhosts) {
// New panel data is ~renderBitmap, so its complement is renderBitmap.
std::memcpy(&regionBuffer[n], &renderBitmap[base], widthBytes);
} else {
std::memcpy(&regionBuffer[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<size_t>(row) * BYTES_PER_ROW + firstByteCol;
for (int c = 0; c < widthBytes; c++) {
const uint8_t value = static_cast<uint8_t>(~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<Gdeq031t10Display*>(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<uint8_t[]>(FRAMEBUFFER_SIZE);
// LVGL needs room for the 8-byte I1 palette ahead of the 1bpp bitmap.
renderFramebuffer = std::make_unique<uint8_t[]>(LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE);
// Scratch for gathering a windowed region (worst case = the whole frame).
regionBuffer = std::make_unique<uint8_t[]>(FRAMEBUFFER_SIZE);
pendingFramebuffer = std::make_unique<uint8_t[]>(FRAMEBUFFER_SIZE);
taskFramebuffer = std::make_unique<uint8_t[]>(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;
}
@@ -1,225 +0,0 @@
#pragma once
#include <Tactility/hal/display/DisplayDevice.h>
#include <Tactility/hal/touch/TouchDevice.h>
#include <atomic>
#include <driver/gpio.h>
#include <driver/spi_master.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <lvgl.h>
#include <memory>
#include <optional>
/**
* 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<tt::hal::touch::TouchDevice> 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<tt::hal::touch::TouchDevice> 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> 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<uint8_t[]> shadowFramebuffer;
/** Render target for LVGL; copied to the panel on flush */
std::unique_ptr<uint8_t[]> renderFramebuffer;
bool initialized = false;
std::atomic<bool> powered = false;
std::atomic<RefreshMode> 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<bool> forceFullRefresh = true;
/** Scratch buffer used to gather a windowed region's bytes for one SPI write. */
std::unique_ptr<uint8_t[]> 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<RefreshMode> 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<uint8_t[]> 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<uint8_t[]> taskFramebuffer;
bool framePending = false;
std::atomic<bool> 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<Configuration> 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<tt::hal::touch::TouchDevice> _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<tt::hal::display::DisplayDriver> _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; }
};
+11
View File
@@ -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
)
@@ -0,0 +1,195 @@
Apache License
==============
_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_
### 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.
+11
View File
@@ -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)
@@ -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
@@ -0,0 +1,3 @@
dependencies:
- TactilityKernel
bindings: bindings
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/bindings/bindings.h>
#include <drivers/gdeq031t10.h>
DEFINE_DEVICETREE(gdeq031t10, struct Gdeq031t10Config)
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
#include <stdbool.h>
#include <stdint.h>
#include <tactility/drivers/gpio.h>
/** 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
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/module.h>
#ifdef __cplusplus
extern "C" {
#endif
extern struct Module gdeq031t10_module;
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,656 @@
// SPDX-License-Identifier: Apache-2.0
#include <drivers/gdeq031t10.h>
#include <gdeq031t10_module.h>
#include <tactility/check.h>
#include <tactility/delay.h>
#include <tactility/device.h>
#include <tactility/driver.h>
#include <tactility/drivers/display.h>
#include <tactility/drivers/esp32_spi.h>
#include <tactility/drivers/gpio_controller.h>
#include <tactility/drivers/spi_controller.h>
#include <tactility/error.h>
#include <tactility/log.h>
#include <tactility/time.h>
#include <driver/spi_master.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <cstdlib>
#include <cstring>
constexpr auto* TAG = "GDEQ031T10";
#define GET_CONFIG(device) (static_cast<const Gdeq031t10Config*>((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<uint16_t>(first_byte_col * 8);
const uint16_t xe = static_cast<uint16_t>(last_byte_col * 8 + 7);
const uint16_t y = static_cast<uint16_t>(first_row);
const uint16_t ye = static_cast<uint16_t>(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<uint8_t>(x));
ok = ok && write_data_byte(internal, static_cast<uint8_t>(xe));
ok = ok && write_data_byte(internal, static_cast<uint8_t>(y >> 8));
ok = ok && write_data_byte(internal, static_cast<uint8_t>(y & 0xFF));
ok = ok && write_data_byte(internal, static_cast<uint8_t>(ye >> 8));
ok = ok && write_data_byte(internal, static_cast<uint8_t>(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<size_t>(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<size_t>(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<Gdeq031t10Internal*>(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<Gdeq031t10Internal*>(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<Gdeq031t10Internal*>(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<const uint8_t*>(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<size_t>(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<Gdeq031t10Internal*>(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<const Esp32SpiConfig*>(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<Gdeq031t10Internal*>(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<uint8_t*>(malloc(FRAMEBUFFER_SIZE));
internal->region_buffer = static_cast<uint8_t*>(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<int>(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<Gdeq031t10Internal*>(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
};
}
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: Apache-2.0
#include <tactility/driver.h>
#include <tactility/module.h>
extern Driver gdeq031t10_driver;
extern "C" {
static Driver* const gdeq031t10_drivers[] = {
&gdeq031t10_driver,
nullptr
};
Module gdeq031t10_module = {
.name = "gdeq031t10",
.drivers = gdeq031t10_drivers
};
} // extern "C"