Add LilyGO T-Deck Max (e-paper) base board support (#552)
This commit is contained in:
@@ -0,0 +1,638 @@
|
||||
#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(®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<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;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
#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; }
|
||||
};
|
||||
Reference in New Issue
Block a user