#include "Gdeq031t10Display.h" #include #include #include #include #include #include // Full lv_theme_t definition (opaque in public lvgl.h) — needed to extend the // mono theme with a custom apply callback, per LVGL's theme-extension pattern. #include constexpr auto* TAG = "GDEQ031T10"; // UC8253-family commands, ported from Xinyuan-LilyGO/T-Deck-MAX's // Display_EPD_W21.cpp reference driver. namespace { constexpr uint8_t CMD_PANEL_SETTING = 0x00; constexpr uint8_t CMD_POWER_ON_OFF = 0x02; // shared opcode: power on when followed by 0x04, power off as standalone 0x02 constexpr uint8_t CMD_POWER_ON = 0x04; constexpr uint8_t CMD_DEEP_SLEEP = 0x07; constexpr uint8_t CMD_DATA_START_OLD = 0x10; constexpr uint8_t CMD_DISPLAY_REFRESH = 0x12; constexpr uint8_t CMD_DATA_START_NEW = 0x13; constexpr uint8_t CMD_VCOM_DATA_INTERVAL = 0x50; constexpr uint8_t CMD_PARTIAL_WINDOW = 0x90; constexpr uint8_t CMD_PARTIAL_IN = 0x91; constexpr uint8_t CMD_PARTIAL_OUT = 0x92; constexpr uint8_t CMD_FAST_MODE_ENABLE = 0xE0; constexpr uint8_t CMD_FAST_MODE_TIMING = 0xE5; constexpr uint8_t DEEP_SLEEP_CHECK_CODE = 0xA5; } bool Gdeq031t10Display::writeCommand(uint8_t command) { gpio_set_level(configuration->pinDc, 0); spi_transaction_t transaction = {}; transaction.length = 8; transaction.tx_buffer = &command; if (spi_device_polling_transmit(spiDevice, &transaction) != ESP_OK) { LOG_E(TAG, "SPI command transfer failed"); return false; } return true; } bool Gdeq031t10Display::writeData(const uint8_t* data, size_t length) { gpio_set_level(configuration->pinDc, 1); spi_transaction_t transaction = {}; transaction.length = length * 8; transaction.tx_buffer = data; if (spi_device_polling_transmit(spiDevice, &transaction) != ESP_OK) { LOG_E(TAG, "SPI data transfer failed"); return false; } return true; } bool Gdeq031t10Display::waitWhileBusy() const { // BUSY pin reads high when the controller is idle/ready. constexpr TickType_t timeout = pdMS_TO_TICKS(5000); const TickType_t start = xTaskGetTickCount(); while (gpio_get_level(configuration->pinBusy) != 1) { if (xTaskGetTickCount() - start > timeout) { LOG_E(TAG, "Timed out waiting for panel BUSY"); return false; } vTaskDelay(pdMS_TO_TICKS(2)); } return true; } void Gdeq031t10Display::reset() const { gpio_set_level(configuration->pinReset, 0); vTaskDelay(pdMS_TO_TICKS(10)); gpio_set_level(configuration->pinReset, 1); vTaskDelay(pdMS_TO_TICKS(10)); } bool Gdeq031t10Display::initFull() { reset(); bool ok = writeCommand(CMD_PANEL_SETTING); ok = ok && writeData(configuration->mirror180 ? 0x13 : 0x1F); ok = ok && writeCommand(CMD_POWER_ON); ok = ok && waitWhileBusy(); if (!ok) { LOG_E(TAG, "Full init failed"); return false; } panelMode = RefreshMode::Full; panelPowerOn = true; return true; } bool Gdeq031t10Display::initFast() { if (!initFull()) { return false; } bool ok = writeCommand(CMD_FAST_MODE_ENABLE); ok = ok && writeData(0x02); ok = ok && writeCommand(CMD_FAST_MODE_TIMING); ok = ok && writeData(0x5A); // ~1.0s if (!ok) { LOG_E(TAG, "Fast mode init failed"); return false; } panelMode = RefreshMode::Fast; return true; } bool Gdeq031t10Display::initSlow() { if (!initFull()) { return false; } bool ok = writeCommand(CMD_FAST_MODE_ENABLE); ok = ok && writeData(0x02); ok = ok && writeCommand(CMD_FAST_MODE_TIMING); ok = ok && writeData(0x6E); // ~1.5s if (!ok) { LOG_E(TAG, "Slow mode init failed"); return false; } panelMode = RefreshMode::Slow; return true; } bool Gdeq031t10Display::initPartial() { if (!initFull()) { return false; } bool ok = writeCommand(CMD_FAST_MODE_ENABLE); ok = ok && writeData(0x02); ok = ok && writeCommand(CMD_FAST_MODE_TIMING); ok = ok && writeData(0x79); ok = ok && writeCommand(CMD_VCOM_DATA_INTERVAL); ok = ok && writeData(0xD7); if (!ok) { LOG_E(TAG, "Partial mode init failed"); return false; } panelMode = RefreshMode::Partial; return true; } bool Gdeq031t10Display::ensurePanelReady(RefreshMode mode) { if (panelMode != mode) { // A mode change needs the full init path: it starts with reset(), which // restores register defaults (required when leaving partial mode, whose // VCOM/data-interval setting would otherwise linger). switch (mode) { case RefreshMode::Full: return initFull(); case RefreshMode::Fast: return initFast(); case RefreshMode::Slow: return initSlow(); case RefreshMode::Partial: return initPartial(); } return false; } else if (!panelPowerOn) { // Registers still hold the mode; only the charge pump was idled. if (!writeCommand(CMD_POWER_ON) || !waitWhileBusy()) { LOG_E(TAG, "Panel did not become ready after power-on"); return false; } panelPowerOn = true; } return true; } bool Gdeq031t10Display::powerOff() { // Command the panel off regardless of whether BUSY confirms it: retrying // forever here would just as likely hang, and a stuck-BUSY panel is // already unusable either way. bool ok = writeCommand(CMD_POWER_ON_OFF); // 0x02 standalone = power off if (!ok || !waitWhileBusy()) { LOG_E(TAG, "Panel did not confirm power-off"); ok = false; } panelPowerOn = false; return ok; } void Gdeq031t10Display::queueRefresh() { xSemaphoreTake(bufferMutex, portMAX_DELAY); // renderFramebuffer always holds a complete frame (single buffer, full // render mode), so the staged copy is self-contained. std::memcpy(pendingFramebuffer.get(), renderFramebuffer.get() + LVGL_I1_PALETTE_SIZE, FRAMEBUFFER_SIZE); framePending = true; xSemaphoreGive(bufferMutex); xTaskNotifyGive(refreshTask); } void Gdeq031t10Display::refreshTaskMain(void* parameter) { auto* self = static_cast(parameter); self->runRefreshTask(); xSemaphoreGive(self->refreshTaskExited); vTaskDelete(nullptr); } void Gdeq031t10Display::runRefreshTask() { while (true) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); if (refreshTaskShouldExit) { return; } // Drain staged frames latest-wins: everything LVGL flushed while the // previous refresh was in progress collapses into one panel update. // A frame staged while the panel is off stays staged; the power-on // path kicks this task to draw it. while (!refreshTaskShouldExit && powered) { xSemaphoreTake(bufferMutex, portMAX_DELAY); const bool havePending = framePending; if (havePending) { std::swap(pendingFramebuffer, taskFramebuffer); framePending = false; } xSemaphoreGive(bufferMutex); if (!havePending) { break; } refresh(); } // The pipeline went idle: drop the charge pump now rather than on a // timer. The mode registers survive a power-off, so the next refresh // only pays a short power-on wait (on this task, invisible to the UI). xSemaphoreTake(panelMutex, portMAX_DELAY); if (initialized && powered && panelPowerOn) { powerOff(); } xSemaphoreGive(panelMutex); } } void Gdeq031t10Display::refresh() { const uint8_t* renderBitmap = taskFramebuffer.get(); constexpr int BYTES_PER_ROW = WIDTH / 8; // Find the bounding box of bytes that differ from what the panel holds. The // panel stores the inverted (shadow) polarity, so compare against ~render. int firstCol = BYTES_PER_ROW, lastCol = -1, firstRow = HEIGHT, lastRow = -1; for (int row = 0; row < HEIGHT; row++) { const size_t base = static_cast(row) * BYTES_PER_ROW; for (int col = 0; col < BYTES_PER_ROW; col++) { if (static_cast(~renderBitmap[base + col]) != shadowFramebuffer[base + col]) { if (col < firstCol) firstCol = col; if (col > lastCol) lastCol = col; if (row < firstRow) firstRow = row; if (row > lastRow) lastRow = row; } } } const bool nothingChanged = (lastCol < 0); if (nothingChanged && !forceFullRefresh) { return; // panel already shows this frame; don't refresh needlessly } // A change covering most of the screen or an explicit request warrants a // full refresh; otherwise refresh just the box. const int changedRows = nothingChanged ? 0 : (lastRow - firstRow + 1); const bool largeChange = changedRows > (HEIGHT * 3 / 4); xSemaphoreTake(panelMutex, portMAX_DELAY); if (!powered || !initialized) { // The display was turned off/stopped while this frame was staged; the // shadow mismatch makes the frame redraw after the next power-on. xSemaphoreGive(panelMutex); return; } if (forceFullRefresh || largeChange || nothingChanged) { // Explicit requests get the best-quality LUT; automatic ghost-clears use // the configured (typically faster) mode. refreshFull(forceFullRefresh ? RefreshMode::Full : currentRefreshMode.load()); forceFullRefresh = false; partialRefreshCount = 0; ghostAreaValid = false; xSemaphoreGive(panelMutex); return; } // Grow the ghost union with this change so the gate sees where partial // updates have accumulated since the last clear. if (!ghostAreaValid) { ghostFirstCol = firstCol; ghostLastCol = lastCol; ghostFirstRow = firstRow; ghostLastRow = lastRow; ghostAreaValid = true; } else { ghostFirstCol = std::min(ghostFirstCol, firstCol); ghostLastCol = std::max(ghostLastCol, lastCol); ghostFirstRow = std::min(ghostFirstRow, firstRow); ghostLastRow = std::max(ghostLastRow, lastRow); } if (partialRefreshCount >= MAX_PARTIAL_REFRESHES) { // Ghost-clear gate. If the accumulated churn is confined to a small // region (a blinking cursor, a line being typed into), scrub just that // window — a full-screen flash there is needless and distracting. Only // widespread churn earns a whole-panel refresh. const int unionWidth = (ghostLastCol - ghostFirstCol + 1) * 8; const int unionHeight = ghostLastRow - ghostFirstRow + 1; const bool unionIsLarge = unionWidth * unionHeight * 4 >= WIDTH * HEIGHT; // >= 25% of the panel if (unionIsLarge) { refreshFull(currentRefreshMode); } else { refreshWindow(ghostFirstCol, ghostLastCol, ghostFirstRow, ghostLastRow, true); } partialRefreshCount = 0; ghostAreaValid = false; } else { refreshWindow(firstCol, lastCol, firstRow, lastRow, false); partialRefreshCount++; } xSemaphoreGive(panelMutex); } void Gdeq031t10Display::refreshFull(RefreshMode mode) { if (!ensurePanelReady(mode)) { LOG_E(TAG, "Skipping full refresh: panel not ready"); return; } const uint8_t* renderBitmap = taskFramebuffer.get(); // shadowFramebuffer keeps the panel-polarity copy of the last frame for the // controller's old/new differential refresh; LVGL's I1 polarity is inverted. if (!writeCommand(CMD_DATA_START_OLD) || !writeData(shadowFramebuffer.get(), FRAMEBUFFER_SIZE)) { LOG_E(TAG, "Failed to send old frame data"); return; } for (size_t i = 0; i < FRAMEBUFFER_SIZE; i++) { shadowFramebuffer[i] = static_cast(~renderBitmap[i]); } if (!writeCommand(CMD_DATA_START_NEW) || !writeData(shadowFramebuffer.get(), FRAMEBUFFER_SIZE)) { LOG_E(TAG, "Failed to send new frame data"); return; } if (!writeCommand(CMD_DISPLAY_REFRESH)) { LOG_E(TAG, "Failed to trigger display refresh"); return; } vTaskDelay(pdMS_TO_TICKS(1)); // datasheet requires >=200us settle before polling BUSY if (!waitWhileBusy()) { LOG_E(TAG, "Full refresh did not complete"); } } void Gdeq031t10Display::refreshWindow(int firstByteCol, int lastByteCol, int firstRow, int lastRow, bool scrubGhosts) { // Partial LUT (fast waveform via temperature force) on a sub-region only. The // RAM window must be byte-aligned in X, which it already is (byte columns). if (!ensurePanelReady(RefreshMode::Partial)) { LOG_E(TAG, "Skipping window refresh: panel not ready"); return; } const uint8_t* renderBitmap = taskFramebuffer.get(); constexpr int BYTES_PER_ROW = WIDTH / 8; const int widthBytes = lastByteCol - firstByteCol + 1; const uint16_t x = static_cast(firstByteCol * 8); const uint16_t xe = static_cast(lastByteCol * 8 + 7); const uint16_t y = static_cast(firstRow); const uint16_t ye = static_cast(lastRow); // Set the partial RAM window (GxEPD2 GDEQ031T10 sequence). bool ok = writeCommand(CMD_PARTIAL_IN); ok = ok && writeCommand(CMD_PARTIAL_WINDOW); ok = ok && writeData(static_cast(x)); ok = ok && writeData(static_cast(xe)); ok = ok && writeData(static_cast(y >> 8)); ok = ok && writeData(static_cast(y & 0xFF)); ok = ok && writeData(static_cast(ye >> 8)); ok = ok && writeData(static_cast(ye & 0xFF)); ok = ok && writeData(0x01); if (!ok) { LOG_E(TAG, "Failed to set partial refresh window"); writeCommand(CMD_PARTIAL_OUT); // best-effort: leave partial-window mode return; } // Old region: normally the region's current panel contents (shadow), // gathered contiguously. For a ghost scrub, feed the complement of the new // data instead: the controller then sees every pixel as changed and drives // each one through a transition, clearing accumulated partial-refresh // ghosting in this window only. size_t n = 0; for (int row = firstRow; row <= lastRow; row++) { const size_t base = static_cast(row) * BYTES_PER_ROW + firstByteCol; if (scrubGhosts) { // New panel data is ~renderBitmap, so its complement is renderBitmap. std::memcpy(®ionBuffer[n], &renderBitmap[base], widthBytes); } else { std::memcpy(®ionBuffer[n], &shadowFramebuffer[base], widthBytes); } n += widthBytes; } if (!writeCommand(CMD_DATA_START_OLD) || !writeData(regionBuffer.get(), n)) { LOG_E(TAG, "Failed to send old window data"); writeCommand(CMD_PARTIAL_OUT); return; } // New region: inverted render, and update the shadow for this region as we go. n = 0; for (int row = firstRow; row <= lastRow; row++) { const size_t base = static_cast(row) * BYTES_PER_ROW + firstByteCol; for (int c = 0; c < widthBytes; c++) { const uint8_t value = static_cast(~renderBitmap[base + c]); regionBuffer[n++] = value; shadowFramebuffer[base + c] = value; } } if (!writeCommand(CMD_DATA_START_NEW) || !writeData(regionBuffer.get(), n)) { LOG_E(TAG, "Failed to send new window data"); writeCommand(CMD_PARTIAL_OUT); return; } if (writeCommand(CMD_DISPLAY_REFRESH)) { vTaskDelay(pdMS_TO_TICKS(1)); if (!waitWhileBusy()) { LOG_E(TAG, "Window refresh did not complete"); } } else { LOG_E(TAG, "Failed to trigger window refresh"); } writeCommand(CMD_PARTIAL_OUT); } void Gdeq031t10Display::flushCallback(lv_display_t* display, const lv_area_t* area, uint8_t* pixelMap) { // pixelMap points into renderFramebuffer (it was passed directly to // lv_display_set_buffers). Only stage the frame here: the physical refresh // takes up to ~1s, so it runs on the driver's refresh task instead of // blocking the LVGL task (which would stall all input handling). auto* self = static_cast(lv_display_get_user_data(display)); if (lv_display_flush_is_last(display)) { self->queueRefresh(); } lv_display_flush_ready(display); } void Gdeq031t10Display::themeApplyCallback(lv_theme_t* /*theme*/, lv_obj_t* obj) { // Keep textarea cursors solid (no blink): on e-paper each blink toggle is a // panel refresh. anim_duration 0 makes lv_textarea skip the blink animation. if (lv_obj_check_type(obj, &lv_textarea_class)) { lv_obj_set_style_anim_duration(obj, 0, LV_PART_CURSOR); } } bool Gdeq031t10Display::start() { if (initialized) { return true; } gpio_config_t outputConfig = { .pin_bit_mask = (1ULL << configuration->pinDc) | (1ULL << configuration->pinReset), .mode = GPIO_MODE_OUTPUT, .pull_up_en = GPIO_PULLUP_DISABLE, .pull_down_en = GPIO_PULLDOWN_DISABLE, .intr_type = GPIO_INTR_DISABLE, }; gpio_config(&outputConfig); gpio_config_t busyConfig = { .pin_bit_mask = 1ULL << configuration->pinBusy, .mode = GPIO_MODE_INPUT, .pull_up_en = GPIO_PULLUP_DISABLE, .pull_down_en = GPIO_PULLDOWN_DISABLE, .intr_type = GPIO_INTR_DISABLE, }; gpio_config(&busyConfig); spi_device_interface_config_t deviceConfig = { .mode = 0, .clock_speed_hz = configuration->clockSpeedHz, .spics_io_num = configuration->pinCs, .queue_size = 1, }; if (spi_bus_add_device(configuration->spiHost, &deviceConfig, &spiDevice) != ESP_OK) { LOG_E(TAG, "Failed to add SPI device"); return false; } if (panelMutex == nullptr) { panelMutex = xSemaphoreCreateMutex(); } if (bufferMutex == nullptr) { bufferMutex = xSemaphoreCreateMutex(); } if (refreshTaskExited == nullptr) { refreshTaskExited = xSemaphoreCreateBinary(); } shadowFramebuffer = std::make_unique(FRAMEBUFFER_SIZE); // LVGL needs room for the 8-byte I1 palette ahead of the 1bpp bitmap. renderFramebuffer = std::make_unique(LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE); // Scratch for gathering a windowed region (worst case = the whole frame). regionBuffer = std::make_unique(FRAMEBUFFER_SIZE); pendingFramebuffer = std::make_unique(FRAMEBUFFER_SIZE); taskFramebuffer = std::make_unique(FRAMEBUFFER_SIZE); std::memset(shadowFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE); std::memset(renderFramebuffer.get(), 0xFF, LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE); std::memset(pendingFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE); std::memset(taskFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE); currentRefreshMode = configuration->defaultRefreshMode; initFull(); refreshTaskShouldExit = false; if (xTaskCreate(&refreshTaskMain, "epd_refresh", REFRESH_TASK_STACK_SIZE, this, REFRESH_TASK_PRIORITY, &refreshTask) != pdPASS) { LOG_E(TAG, "Failed to create refresh task"); spi_bus_remove_device(spiDevice); spiDevice = nullptr; shadowFramebuffer.reset(); renderFramebuffer.reset(); regionBuffer.reset(); pendingFramebuffer.reset(); taskFramebuffer.reset(); return false; } powered = true; initialized = true; return true; } bool Gdeq031t10Display::stop() { if (!initialized) { return true; } stopLvgl(); // Join the refresh task before touching the panel: it may be mid-refresh. refreshTaskShouldExit = true; xTaskNotifyGive(refreshTask); xSemaphoreTake(refreshTaskExited, portMAX_DELAY); refreshTask = nullptr; setPowerOn(false); xSemaphoreTake(panelMutex, portMAX_DELAY); initialized = false; spi_bus_remove_device(spiDevice); spiDevice = nullptr; shadowFramebuffer.reset(); renderFramebuffer.reset(); regionBuffer.reset(); pendingFramebuffer.reset(); taskFramebuffer.reset(); framePending = false; xSemaphoreGive(panelMutex); return true; } void Gdeq031t10Display::setPowerOn(bool turnOn) { if (turnOn == powered) { return; } xSemaphoreTake(panelMutex, portMAX_DELAY); if (turnOn) { initFull(); // toggling RST also wakes the panel from deep sleep } else { if (panelPowerOn) { powerOff(); } vTaskDelay(pdMS_TO_TICKS(100)); writeCommand(CMD_DEEP_SLEEP); writeData(DEEP_SLEEP_CHECK_CODE); // Deep sleep needs a reset to wake, which restores register defaults. panelMode.reset(); } powered = turnOn; xSemaphoreGive(panelMutex); if (turnOn && refreshTask != nullptr) { // initFull left the charge pump on. Kick the refresh task: it redraws // any frame staged while the panel was off, then idles the pump down. xTaskNotifyGive(refreshTask); } } void Gdeq031t10Display::requestFullRefresh() { forceFullRefresh = true; } bool Gdeq031t10Display::startLvgl() { if (lvglDisplay != nullptr) { return true; } lvglDisplay = lv_display_create(Gdeq031t10Display::WIDTH, Gdeq031t10Display::HEIGHT); if (lvglDisplay == nullptr) { return false; } lv_display_set_user_data(lvglDisplay, this); lv_display_set_color_format(lvglDisplay, LV_COLOR_FORMAT_I1); // The default colour theme renders accent-coloured text/icons that threshold // to near-invisible on a 1bpp panel. Apply LVGL's monochrome theme (light // background, dark foreground) so UI content shows as solid black on white. lv_theme_t* baseTheme = lv_theme_mono_init(lvglDisplay, false, LV_FONT_DEFAULT); // Chain a theme on top of the mono theme that disables the textarea cursor // blink. A blinking cursor invalidates its region ~twice a second, and on // e-paper every invalidation is a panel refresh, so text-entry screens (e.g. // the Wi-Fi password field) flash continuously. The mono theme still applies // first (it's the parent); themeApplyCallback only pins the cursor solid. static lv_theme_t epaperTheme; epaperTheme = *baseTheme; lv_theme_set_parent(&epaperTheme, baseTheme); lv_theme_set_apply_cb(&epaperTheme, &Gdeq031t10Display::themeApplyCallback); lv_display_set_theme(lvglDisplay, &epaperTheme); lv_display_set_render_mode(lvglDisplay, LV_DISPLAY_RENDER_MODE_FULL); lv_display_set_buffers(lvglDisplay, renderFramebuffer.get(), nullptr, LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE, LV_DISPLAY_RENDER_MODE_FULL); lv_display_set_flush_cb(lvglDisplay, flushCallback); return true; } bool Gdeq031t10Display::stopLvgl() { if (lvglDisplay == nullptr) { return true; } lv_display_delete(lvglDisplay); lvglDisplay = nullptr; return true; }