Add LilyGO T-Deck Max (e-paper) base board support (#552)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRC_DIRS "Source"
|
||||
INCLUDE_DIRS "Source"
|
||||
REQUIRES Tactility driver
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
@@ -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; }
|
||||
};
|
||||
@@ -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(xl9555-module
|
||||
SRCS ${SOURCE_FILES}
|
||||
INCLUDE_DIRS include/
|
||||
REQUIRES TactilityKernel
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
Apache License
|
||||
==============
|
||||
|
||||
_Version 2.0, January 2004_
|
||||
_<<http://www.apache.org/licenses/>>_
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# XL9555 I/O expander
|
||||
|
||||
A driver for the `XL9555` 16-bit I2C-bus I/O expander, used by several LilyGO boards
|
||||
(e.g. T-Deck Pro Max) for power-rail enables, reset lines, and antenna/audio routing.
|
||||
Its register map is PCA9555-compatible: two 8-bit ports for input, output, polarity
|
||||
inversion, and direction, addressed as pins 0-15 (port 0 = pins 0-7, port 1 = pins 8-15).
|
||||
|
||||
It does not support pull-up/down resistors or high-impedance outputs; requesting those
|
||||
flags returns `ERROR_NOT_SUPPORTED`.
|
||||
|
||||
License: [Apache v2.0](LICENSE-Apache-2.0.md)
|
||||
@@ -0,0 +1,5 @@
|
||||
description: XLSEMI XL9555 16-bit I2C-bus I/O expander (PCA9555-register-compatible)
|
||||
|
||||
include: ["i2c-device.yaml"]
|
||||
|
||||
compatible: "xlsemi,xl9555"
|
||||
@@ -0,0 +1,3 @@
|
||||
dependencies:
|
||||
- TactilityKernel
|
||||
bindings: bindings
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <tactility/bindings/bindings.h>
|
||||
#include <drivers/xl9555.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
DEFINE_DEVICETREE(xl9555, struct Xl9555Config)
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
struct Xl9555Config {
|
||||
/** Address on bus */
|
||||
uint8_t address;
|
||||
};
|
||||
|
||||
#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 xl9555_module;
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/driver.h>
|
||||
#include <tactility/module.h>
|
||||
|
||||
extern "C" {
|
||||
|
||||
extern Driver xl9555_driver;
|
||||
|
||||
static error_t start() {
|
||||
/* We crash when construct fails, because if a single driver fails to construct,
|
||||
* there is no guarantee that the previously constructed drivers can be destroyed */
|
||||
check(driver_construct_add(&xl9555_driver) == ERROR_NONE);
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
static error_t stop() {
|
||||
/* We crash when destruct fails, because if a single driver fails to destruct,
|
||||
* there is no guarantee that the previously destroyed drivers can be recovered */
|
||||
check(driver_remove_destruct(&xl9555_driver) == ERROR_NONE);
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
Module xl9555_module = {
|
||||
.name = "xl9555",
|
||||
.start = start,
|
||||
.stop = stop,
|
||||
.symbols = nullptr,
|
||||
.internal = nullptr
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include <drivers/xl9555.h>
|
||||
#include <xl9555_module.h>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/driver.h>
|
||||
#include <tactility/drivers/gpio_controller.h>
|
||||
#include <tactility/drivers/gpio_descriptor.h>
|
||||
#include <tactility/drivers/i2c_controller.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
#define TAG "XL9555"
|
||||
|
||||
#define GET_CONFIG(device) (static_cast<const Xl9555Config*>((device)->config))
|
||||
|
||||
// PCA9555-compatible register map: one register per function per 8-pin port.
|
||||
constexpr auto XL9555_REGISTER_INPUT_PORT0 = 0x00;
|
||||
constexpr auto XL9555_REGISTER_OUTPUT_PORT0 = 0x02;
|
||||
constexpr auto XL9555_REGISTER_POLARITY_PORT0 = 0x04;
|
||||
constexpr auto XL9555_REGISTER_CONFIG_PORT0 = 0x06;
|
||||
|
||||
static inline uint8_t port_of(GpioDescriptor* descriptor) {
|
||||
return descriptor->pin >> 3;
|
||||
}
|
||||
|
||||
static inline uint8_t bit_of(GpioDescriptor* descriptor) {
|
||||
return 1 << (descriptor->pin & 0x7);
|
||||
}
|
||||
|
||||
static error_t start(Device* device) {
|
||||
auto* parent = device_get_parent(device);
|
||||
check(device_get_type(parent) == &I2C_CONTROLLER_TYPE);
|
||||
|
||||
return gpio_controller_init_descriptors(device, 16, nullptr);
|
||||
}
|
||||
|
||||
static error_t stop(Device* device) {
|
||||
check(gpio_controller_deinit_descriptors(device) == ERROR_NONE);
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
|
||||
static error_t set_level(GpioDescriptor* descriptor, bool high) {
|
||||
auto* device = descriptor->controller;
|
||||
auto* parent = device_get_parent(device);
|
||||
auto address = GET_CONFIG(device)->address;
|
||||
auto reg = static_cast<uint8_t>(XL9555_REGISTER_OUTPUT_PORT0 + port_of(descriptor));
|
||||
auto bit = bit_of(descriptor);
|
||||
|
||||
// i2c_controller_register8_{set,reset}_bits() do a separate read then
|
||||
// write; without this lock, concurrent updates to different pins on the
|
||||
// same output port register can clobber each other.
|
||||
device_lock(device);
|
||||
error_t err = high
|
||||
? i2c_controller_register8_set_bits(parent, address, reg, bit, portMAX_DELAY)
|
||||
: i2c_controller_register8_reset_bits(parent, address, reg, bit, portMAX_DELAY);
|
||||
device_unlock(device);
|
||||
return err;
|
||||
}
|
||||
|
||||
static error_t get_level(GpioDescriptor* descriptor, bool* high) {
|
||||
auto* device = descriptor->controller;
|
||||
auto* parent = device_get_parent(device);
|
||||
auto address = GET_CONFIG(device)->address;
|
||||
auto reg = static_cast<uint8_t>(XL9555_REGISTER_INPUT_PORT0 + port_of(descriptor));
|
||||
uint8_t bits;
|
||||
|
||||
error_t err = i2c_controller_register8_get(parent, address, reg, &bits, portMAX_DELAY);
|
||||
if (err != ERROR_NONE) {
|
||||
return err;
|
||||
}
|
||||
|
||||
*high = (bits & bit_of(descriptor)) != 0;
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
static error_t set_flags(GpioDescriptor* descriptor, gpio_flags_t flags) {
|
||||
// The XL9555 only supports direction and polarity inversion. Pull-up/down and
|
||||
// high-impedance are not present in its PCA9555-compatible register map.
|
||||
if (flags & (GPIO_FLAG_PULL_UP | GPIO_FLAG_PULL_DOWN | GPIO_FLAG_HIGH_IMPEDANCE)) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
// The polarity register only inverts what's read back from an input pin;
|
||||
// set_level() still drives outputs at the raw level. Accepting ACTIVE_LOW
|
||||
// on an output would silently not do what it implies.
|
||||
if ((flags & GPIO_FLAG_ACTIVE_LOW) && (flags & GPIO_FLAG_DIRECTION_OUTPUT)) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
auto* device = descriptor->controller;
|
||||
auto* parent = device_get_parent(device);
|
||||
auto address = GET_CONFIG(device)->address;
|
||||
auto config_reg = static_cast<uint8_t>(XL9555_REGISTER_CONFIG_PORT0 + port_of(descriptor));
|
||||
auto polarity_reg = static_cast<uint8_t>(XL9555_REGISTER_POLARITY_PORT0 + port_of(descriptor));
|
||||
auto bit = bit_of(descriptor);
|
||||
error_t err;
|
||||
|
||||
// Locked as a whole: direction and polarity are two separate RMW register
|
||||
// writes, and both should apply atomically with respect to other set_flags
|
||||
// / set_level calls on this device.
|
||||
device_lock(device);
|
||||
|
||||
// Direction: configuration bit is 1 for input, 0 for output.
|
||||
if (flags & GPIO_FLAG_DIRECTION_OUTPUT) {
|
||||
err = i2c_controller_register8_reset_bits(parent, address, config_reg, bit, portMAX_DELAY);
|
||||
} else {
|
||||
err = i2c_controller_register8_set_bits(parent, address, config_reg, bit, portMAX_DELAY);
|
||||
}
|
||||
|
||||
if (err != ERROR_NONE) {
|
||||
device_unlock(device);
|
||||
return err;
|
||||
}
|
||||
|
||||
// Polarity inversion (mainly relevant for active-low inputs).
|
||||
if (flags & GPIO_FLAG_ACTIVE_LOW) {
|
||||
err = i2c_controller_register8_set_bits(parent, address, polarity_reg, bit, portMAX_DELAY);
|
||||
} else {
|
||||
err = i2c_controller_register8_reset_bits(parent, address, polarity_reg, bit, portMAX_DELAY);
|
||||
}
|
||||
|
||||
device_unlock(device);
|
||||
return err;
|
||||
}
|
||||
|
||||
static error_t get_flags(GpioDescriptor* descriptor, gpio_flags_t* flags) {
|
||||
auto* device = descriptor->controller;
|
||||
auto* parent = device_get_parent(device);
|
||||
auto address = GET_CONFIG(device)->address;
|
||||
auto config_reg = static_cast<uint8_t>(XL9555_REGISTER_CONFIG_PORT0 + port_of(descriptor));
|
||||
auto polarity_reg = static_cast<uint8_t>(XL9555_REGISTER_POLARITY_PORT0 + port_of(descriptor));
|
||||
auto bit = bit_of(descriptor);
|
||||
uint8_t val;
|
||||
error_t err;
|
||||
|
||||
gpio_flags_t f = GPIO_FLAG_NONE;
|
||||
|
||||
err = i2c_controller_register8_get(parent, address, config_reg, &val, portMAX_DELAY);
|
||||
if (err != ERROR_NONE) return err;
|
||||
f |= (val & bit) ? GPIO_FLAG_DIRECTION_INPUT : GPIO_FLAG_DIRECTION_OUTPUT;
|
||||
|
||||
err = i2c_controller_register8_get(parent, address, polarity_reg, &val, portMAX_DELAY);
|
||||
if (err != ERROR_NONE) return err;
|
||||
f |= (val & bit) ? GPIO_FLAG_ACTIVE_LOW : GPIO_FLAG_ACTIVE_HIGH;
|
||||
|
||||
*flags = f;
|
||||
return ERROR_NONE;
|
||||
}
|
||||
|
||||
static error_t get_native_pin_number(GpioDescriptor* descriptor, void* pin_number) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
static error_t add_callback(GpioDescriptor* descriptor, void (*callback)(void*), void* arg) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
static error_t remove_callback(GpioDescriptor* descriptor) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
static error_t enable_interrupt(GpioDescriptor* descriptor) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
static error_t disable_interrupt(GpioDescriptor* descriptor) {
|
||||
return ERROR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
const static GpioControllerApi xl9555_gpio_api = {
|
||||
.set_level = set_level,
|
||||
.get_level = get_level,
|
||||
.set_flags = set_flags,
|
||||
.get_flags = get_flags,
|
||||
.get_native_pin_number = get_native_pin_number,
|
||||
.add_callback = add_callback,
|
||||
.remove_callback = remove_callback,
|
||||
.enable_interrupt = enable_interrupt,
|
||||
.disable_interrupt = disable_interrupt
|
||||
};
|
||||
|
||||
Driver xl9555_driver = {
|
||||
.name = "xl9555",
|
||||
.compatible = (const char*[]) { "xlsemi,xl9555", nullptr },
|
||||
.start_device = start,
|
||||
.stop_device = stop,
|
||||
.api = static_cast<const void*>(&xl9555_gpio_api),
|
||||
.device_type = &GPIO_CONTROLLER_TYPE,
|
||||
.owner = &xl9555_module,
|
||||
.internal = nullptr
|
||||
};
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user