Add LilyGO T-Deck Max (e-paper) base board support (#552)

This commit is contained in:
Crazypedia
2026-07-08 02:40:09 -04:00
committed by GitHub
parent f4f91f81d6
commit d6ac070e30
27 changed files with 2274 additions and 0 deletions
+1
View File
@@ -57,6 +57,7 @@ jobs:
{ id: guition-jc8048w550c, arch: esp32s3 }, { id: guition-jc8048w550c, arch: esp32s3 },
{ id: heltec-wifi-lora-32-v3, arch: esp32s3 }, { id: heltec-wifi-lora-32-v3, arch: esp32s3 },
{ id: lilygo-tdeck, arch: esp32s3 }, { id: lilygo-tdeck, arch: esp32s3 },
{ id: lilygo-tdeck-max, arch: esp32s3 },
{ id: lilygo-thmi, arch: esp32s3 }, { id: lilygo-thmi, arch: esp32s3 },
{ id: lilygo-tdongle-s3, arch: esp32s3 }, { id: lilygo-tdongle-s3, arch: esp32s3 },
{ id: lilygo-tdisplay-s3, arch: esp32s3 }, { id: lilygo-tdisplay-s3, arch: esp32s3 },
+7
View File
@@ -0,0 +1,7 @@
file(GLOB_RECURSE SOURCE_FILES Source/*.c*)
idf_component_register(
SRCS ${SOURCE_FILES}
INCLUDE_DIRS "Source"
REQUIRES Tactility GDEQ031T10 TCA8418 driver
)
@@ -0,0 +1,91 @@
#include "devices/Display.h"
#include "devices/TdeckmaxKeyboard.h"
#include <Tactility/hal/Configuration.h>
#include <tactility/check.h>
#include <tactility/delay.h>
#include <tactility/device.h>
#include <tactility/drivers/esp32_spi.h>
#include <tactility/drivers/gpio_controller.h>
using namespace tt::hal;
// Reset lines routed through the XL9555 IO expander (P-numbers from the vendor
// lib/TDeckMaxBoard/src/TDeckMaxBoard.h). Both are active low.
constexpr auto XL9555_PIN_TOUCH_RST = 7; // P07
constexpr auto XL9555_PIN_KEY_RST = 9; // P11
// Release the touch and keyboard reset lines held by the XL9555. Without this
// the touch controller may stay in a half-powered state and the keyboard's
// TCA8418 is held in reset, so neither responds. Mirrors the vendor factory
// firmware's XL9555 init (examples/factory/factory.ino).
static void initIoExpander() {
auto* xl9555 = device_find_by_name("xl9555");
if (xl9555 == nullptr) {
// Boot anyway; display works without the expander, but touch/keyboard won't.
return;
}
auto* touch_rst = gpio_descriptor_acquire(xl9555, XL9555_PIN_TOUCH_RST, GPIO_OWNER_GPIO);
auto* key_rst = gpio_descriptor_acquire(xl9555, XL9555_PIN_KEY_RST, GPIO_OWNER_GPIO);
if (touch_rst != nullptr) {
gpio_descriptor_set_flags(touch_rst, GPIO_FLAG_DIRECTION_OUTPUT);
gpio_descriptor_set_level(touch_rst, false);
}
if (key_rst != nullptr) {
gpio_descriptor_set_flags(key_rst, GPIO_FLAG_DIRECTION_OUTPUT);
gpio_descriptor_set_level(key_rst, false);
}
delay_millis(20);
// Release both resets and give the controllers time to boot before they're probed.
if (touch_rst != nullptr) {
gpio_descriptor_set_level(touch_rst, true);
gpio_descriptor_release(touch_rst);
}
if (key_rst != nullptr) {
gpio_descriptor_set_level(key_rst, true);
gpio_descriptor_release(key_rst);
}
delay_millis(60);
}
static bool initBoot() {
// The LoRa and SD chip-selects share the EPD's SPI bus but aren't wired up
// yet. spi0's start() already drives every cs-gpio high during kernel init;
// re-assert deselection before the EPD driver first transacts, as a cheap
// guard against either chip latching stray EPD command bytes (same pattern
// as the esp32_sdspi mount path).
auto* spi0 = device_find_by_name("spi0");
check(spi0 != nullptr);
esp32_spi_deselect_all_cs(spi0);
initIoExpander();
return true;
}
static DeviceVector createDevices() {
auto* i2c = device_find_by_name("i2c0");
check(i2c != nullptr);
// SD card is not created here: it's an espressif,esp32-sdspi node in the
// devicetree (sdcard@1 under spi0), started by Hal::init's mountAll().
DeviceVector devices = {
createDisplay()
};
auto keypad = std::make_shared<Tca8418>(i2c);
devices.push_back(keypad);
devices.push_back(std::make_shared<TdeckmaxKeyboard>(keypad));
return devices;
}
extern const Configuration hardwareConfiguration = {
.initBoot = initBoot,
.createDevices = createDevices
};
@@ -0,0 +1,225 @@
#include "Cst66xxTouch.h"
#include <tactility/log.h>
#include <Tactility/app/App.h>
#include <tactility/drivers/gpio_controller.h>
#include <tactility/drivers/i2c_controller.h>
#include <freertos/FreeRTOS.h>
// Touch reset is wired to XL9555 P07 (active low).
static constexpr uint32_t XL9555_PIN_TOUCH_RST = 7;
constexpr auto* TAG = "CST66xx";
static constexpr TickType_t I2C_TIMEOUT = pdMS_TO_TICKS(20);
// The protocol uses 4-byte register addresses, taken from the vendor's
// lib/HynTouch/src/hyn_cst66xx.c.
// Normal-mode setup sequence (cst66xx_set_workmode, NOMAL_MODE).
static constexpr uint8_t CMD_DISABLE_LP_I2C[4] = {0xD0, 0x00, 0x04, 0x00};
static constexpr uint8_t CMD_NORMAL_A[4] = {0xD0, 0x00, 0x00, 0x00};
static constexpr uint8_t CMD_NORMAL_B[4] = {0xD0, 0x00, 0x0C, 0x00};
static constexpr uint8_t CMD_NORMAL_C[4] = {0xD0, 0x00, 0x01, 0x00};
// Read-point register: write these 4 bytes, then read the touch frame.
static constexpr uint8_t REG_READ_POINT[4] = {0xD0, 0x07, 0x00, 0x00};
// Acknowledge/clear after each read.
static constexpr uint8_t CMD_ACK[4] = {0xD0, 0x00, 0x02, 0xAB};
// Identity/config register (cst66xx_updata_tpinfo): read 50 bytes; buf[2]==0xCA
// && buf[3]==0xCA confirms a CST66xx.
static constexpr uint8_t REG_INFO[4] = {0xD0, 0x03, 0x00, 0x00};
static void setNormalMode(::Device* i2c, uint8_t address) {
i2c_controller_write(i2c, address, CMD_DISABLE_LP_I2C, sizeof(CMD_DISABLE_LP_I2C), I2C_TIMEOUT);
vTaskDelay(pdMS_TO_TICKS(1));
i2c_controller_write(i2c, address, CMD_DISABLE_LP_I2C, sizeof(CMD_DISABLE_LP_I2C), I2C_TIMEOUT);
i2c_controller_write(i2c, address, CMD_NORMAL_A, sizeof(CMD_NORMAL_A), I2C_TIMEOUT);
i2c_controller_write(i2c, address, CMD_NORMAL_B, sizeof(CMD_NORMAL_B), I2C_TIMEOUT);
i2c_controller_write(i2c, address, CMD_NORMAL_C, sizeof(CMD_NORMAL_C), I2C_TIMEOUT);
}
bool Cst66xxTouch::start() {
// Reset the controller (XL9555 P07, active low). The chip only re-initialises
// into normal reporting mode after a clean reset pulse.
auto* xl9555 = device_find_by_name("xl9555");
if (xl9555 != nullptr) {
auto* rst = gpio_descriptor_acquire(xl9555, XL9555_PIN_TOUCH_RST, GPIO_OWNER_GPIO);
if (rst != nullptr) {
gpio_descriptor_set_flags(rst, GPIO_FLAG_DIRECTION_OUTPUT);
gpio_descriptor_set_level(rst, false);
vTaskDelay(pdMS_TO_TICKS(10));
gpio_descriptor_set_level(rst, true);
gpio_descriptor_release(rst);
}
}
// The reset pulse exits boot mode; give the controller time to re-init.
vTaskDelay(pdMS_TO_TICKS(80));
// Put the controller into normal reporting mode and verify the identity. The
// first read after reset can miss, so retry like the vendor's
// cst66xx_updata_tpinfo (read 0xD0030000, expect buf[2]==buf[3]==0xCA).
auto* i2c = configuration.i2cController;
const uint8_t address = configuration.address;
bool confirmed = false;
for (int attempt = 0; attempt < 5 && !confirmed; ++attempt) {
setNormalMode(i2c, address);
uint8_t info[50] = {};
if (i2c_controller_write(i2c, address, REG_INFO, sizeof(REG_INFO), I2C_TIMEOUT) == ERROR_NONE &&
i2c_controller_read(i2c, address, info, sizeof(info), I2C_TIMEOUT) == ERROR_NONE &&
info[2] == 0xCA && info[3] == 0xCA) {
confirmed = true;
} else {
vTaskDelay(pdMS_TO_TICKS(10));
}
}
if (confirmed) {
LOG_I(TAG, "CST66xx initialised");
} else {
LOG_W(TAG, "CST66xx identity not confirmed (continuing)");
}
return true;
}
bool Cst66xxTouch::stop() {
return true;
}
bool Cst66xxTouch::readPoint(int16_t& x, int16_t& y) {
// CST66xx frame (cst66xx_report): write the read-point register, then read 9
// bytes. buf[2]=report type (0xFF=position/key), buf[3] low nibble = finger
// count, high nibble = key count. The first 5-byte slot (buf[4..8]) is a key
// slot when key count > 0, otherwise the first finger; further fingers, if
// any, follow at buf[index+...].
uint8_t buf[9] = {};
error_t err = i2c_controller_write(configuration.i2cController, configuration.address, REG_READ_POINT, sizeof(REG_READ_POINT), I2C_TIMEOUT);
if (err == ERROR_NONE) {
err = i2c_controller_read(configuration.i2cController, configuration.address, buf, sizeof(buf), I2C_TIMEOUT);
}
if (err != ERROR_NONE) {
return false;
}
// Acknowledge/clear the frame after every read.
i2c_controller_write(configuration.i2cController, configuration.address, CMD_ACK, sizeof(CMD_ACK), I2C_TIMEOUT);
const uint8_t reportType = buf[2];
const uint8_t fingerCount = buf[3] & 0x0F;
const uint8_t keyCount = (buf[3] & 0xF0) >> 4;
// Bezel touch-keys are reported in the first slot (buf[8]): low nibble = key
// id, high nibble = state (non-zero = pressed). The controller reports keys
// mutually exclusively with finger coordinates, so a key frame never carries
// a touch point.
if (reportType == 0xFF && keyCount > 0) {
const uint8_t keyByte = buf[8];
handleBezelKey(keyByte & 0x0F, (keyByte >> 4) != 0);
return false;
}
// No key in this frame: clear the latch so the next press fires once.
bezelKeyDown = false;
if (reportType != 0xFF || fingerCount < 1) {
return false;
}
// First finger's slot follows any key slots.
const uint8_t index = keyCount * 5;
const uint8_t event = buf[index + 8] >> 4; // 0 = up
if (event == 0) {
return false;
}
int16_t rawX = static_cast<int16_t>(buf[index + 4] | (static_cast<uint16_t>(buf[index + 7] & 0x0F) << 8));
int16_t rawY = static_cast<int16_t>(buf[index + 5] | (static_cast<uint16_t>(buf[index + 7] & 0xF0) << 4));
if (configuration.swapXy) {
std::swap(rawX, rawY);
}
if (configuration.mirrorX) {
rawX = static_cast<int16_t>(configuration.width - 1 - rawX);
}
if (configuration.mirrorY) {
rawY = static_cast<int16_t>(configuration.height - 1 - rawY);
}
x = rawX;
y = rawY;
return true;
}
// Bezel touch-key ids as reported by the CST66xx, left to right (confirmed on
// hardware). To re-map a button, change the action in the switch below — the
// mapping is also documented in NOTES.md.
static constexpr uint8_t BEZEL_KEY_HEART = 0; // left
static constexpr uint8_t BEZEL_KEY_SPEECH = 1; // centre
static constexpr uint8_t BEZEL_KEY_AIRPLANE = 2; // right
void Cst66xxTouch::handleBezelKey(uint8_t keyId, bool pressed) {
if (!pressed) {
bezelKeyDown = false;
return;
}
if (bezelKeyDown) {
return; // still held; the press edge was already handled
}
bezelKeyDown = true;
LOG_D(TAG, "bezel key %u", keyId);
// Bezel button -> navigation action. Edit these cases to customise:
switch (keyId) {
case BEZEL_KEY_HEART: // Back: stop the current app (no-op at the launcher root)
tt::app::stop();
break;
// Home (BEZEL_KEY_SPEECH) and Recents (BEZEL_KEY_AIRPLANE) are intentionally
// unbound: tt::app::start() always pushes a new instance onto the app
// stack, so repeated presses would keep growing it (Launcher/AppList
// instances never get cleaned up) and leak memory over time. The app
// stack has no API yet to collapse back to an existing instance instead
// of pushing a new one; wire these up once that exists.
default:
break;
}
}
void Cst66xxTouch::readCallback(lv_indev_t* indev, lv_indev_data_t* data) {
auto* self = static_cast<Cst66xxTouch*>(lv_indev_get_user_data(indev));
int16_t x;
int16_t y;
if (self->readPoint(x, y)) {
self->lastX = x;
self->lastY = y;
data->point.x = x;
data->point.y = y;
data->state = LV_INDEV_STATE_PRESSED;
} else {
// Report the release at the last known position.
data->point.x = self->lastX;
data->point.y = self->lastY;
data->state = LV_INDEV_STATE_RELEASED;
}
}
bool Cst66xxTouch::startLvgl(lv_display_t* display) {
if (indev != nullptr) {
return true;
}
indev = lv_indev_create();
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
lv_indev_set_read_cb(indev, &readCallback);
lv_indev_set_display(indev, display);
lv_indev_set_user_data(indev, this);
return true;
}
bool Cst66xxTouch::stopLvgl() {
if (indev == nullptr) {
return true;
}
lv_indev_delete(indev);
indev = nullptr;
return true;
}
@@ -0,0 +1,54 @@
#pragma once
#include <Tactility/hal/touch/TouchDevice.h>
#include <tactility/device.h>
// Capacitive touch for the LilyGO T-Deck Max's Hynitron CST66xx controller at
// I2C 0x1A. (The vendor docs label it "CST328", but the vendor HynTouch driver
// probes cst66xx first and that is what answers here.) This is a minimal,
// single-touch port of the vendor's hyn_cst66xx.c protocol for LVGL pointer input.
class Cst66xxTouch final : public tt::hal::touch::TouchDevice {
public:
struct Configuration {
::Device* i2cController;
uint8_t address;
uint16_t width;
uint16_t height;
bool swapXy;
bool mirrorX;
bool mirrorY;
};
explicit Cst66xxTouch(const Configuration& configuration) : configuration(configuration) {}
std::string getName() const override { return "CST66xx"; }
std::string getDescription() const override { return "CST66xx I2C capacitive touch"; }
bool start() override;
bool stop() override;
bool supportsLvgl() const override { return true; }
bool startLvgl(lv_display_t* display) override;
bool stopLvgl() override;
lv_indev_t* getLvglIndev() override { return indev; }
bool supportsTouchDriver() override { return false; }
std::shared_ptr<tt::hal::touch::TouchDriver> getTouchDriver() override { return nullptr; }
private:
Configuration configuration;
lv_indev_t* indev = nullptr;
int16_t lastX = 0;
int16_t lastY = 0;
// The CST66xx also reports the three bezel touch-keys (heart / speech-bubble /
// paper-airplane). bezelKeyDown latches a press so we act once on the rising
// edge rather than every poll while the key is held.
bool bezelKeyDown = false;
bool readPoint(int16_t& x, int16_t& y);
void handleBezelKey(uint8_t keyId, bool pressed);
static void readCallback(lv_indev_t* indev, lv_indev_data_t* data);
};
@@ -0,0 +1,57 @@
#include "Display.h"
#include "Cst66xxTouch.h"
#include <Gdeq031t10Display.h>
#include <tactility/log.h>
#include <tactility/device.h>
constexpr auto* TAG = "TdeckmaxDisplay";
// Pins and I2C address from Xinyuan-LilyGO/T-Deck-MAX's
// lib/TDeckMaxBoard/src/TDeckMaxBoard.h and docs/pinmap.md.
constexpr auto EPD_SPI_HOST = SPI2_HOST;
constexpr auto EPD_PIN_CS = GPIO_NUM_34;
constexpr auto EPD_PIN_DC = GPIO_NUM_35;
constexpr auto EPD_PIN_RST = GPIO_NUM_9;
constexpr auto EPD_PIN_BUSY = GPIO_NUM_37;
constexpr uint16_t TOUCH_I2C_ADDRESS = 0x1A;
static std::shared_ptr<tt::hal::touch::TouchDevice> createTouch() {
auto* i2c = device_find_by_name("i2c0");
if (i2c == nullptr) {
LOG_E(TAG, "i2c0 not found, booting without touch");
return nullptr;
}
// The touch reset line is released earlier via the XL9555 expander (see
// Configuration.cpp). Orientation flags are starting guesses; adjust after
// checking on hardware.
const Cst66xxTouch::Configuration configuration = {
.i2cController = i2c,
.address = TOUCH_I2C_ADDRESS,
.width = Gdeq031t10Display::WIDTH,
.height = Gdeq031t10Display::HEIGHT,
.swapXy = false,
.mirrorX = false,
.mirrorY = false,
};
return std::make_shared<Cst66xxTouch>(configuration);
}
std::shared_ptr<tt::hal::display::DisplayDevice> createDisplay() {
auto configuration = std::make_unique<Gdeq031t10Display::Configuration>(
EPD_SPI_HOST,
EPD_PIN_CS,
EPD_PIN_DC,
EPD_PIN_RST,
EPD_PIN_BUSY,
createTouch(),
10'000'000,
// Default to a fast (~1s) refresh for the automatic ghost-clears so the
// full-screen flash they cause is as short as possible.
Gdeq031t10Display::RefreshMode::Fast
);
return std::make_shared<Gdeq031t10Display>(std::move(configuration));
}
@@ -0,0 +1,5 @@
#pragma once
#include <Tactility/hal/display/DisplayDevice.h>
std::shared_ptr<tt::hal::display::DisplayDevice> createDisplay();
@@ -0,0 +1,249 @@
#include "TdeckmaxKeyboard.h"
#include <tactility/log.h>
#include <driver/i2c.h>
#include <driver/gpio.h>
constexpr auto* TAG = "TdeckmaxKeyboard";
// Keyboard backlight (LED_PWM), GPIO42 on the T-Deck Max.
constexpr auto BACKLIGHT = GPIO_NUM_42;
constexpr auto KB_ROWS = 4;
constexpr auto KB_COLS = 10;
// Keymaps are written in the vendor's row/column orientation
// (Xinyuan-LilyGO/T-Deck-MAX examples/factory/peri_keypad.cpp). The TCA8418
// columns are wired in reverse, so the vendor reads keymap[row][9 - col]. We do
// the same reversal in processKeyboard(), which keeps these tables a direct,
// verifiable copy of the vendor layout.
//
// Modifier keys (ALT and SYM) are blanked here ('\0') and handled by position:
// ALT -> shift/uppercase, at vendor column 0 of row 2
// SYM -> symbol layer, at vendor column 8 of row 3
// Lowercase (base) layer
static constexpr char keymap_lc[KB_ROWS][KB_COLS] = {
{'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'},
{'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', LV_KEY_BACKSPACE},
{'\0', 'z', 'x', 'c', 'v', 'b', 'n', 'm', '$', LV_KEY_ENTER},
{'\0', '\0', '\0', '\0', '\0', LV_KEY_PREV, '0', ' ', '\0', LV_KEY_NEXT}
};
// Uppercase layer (ALT held or caps toggled)
static constexpr char keymap_uc[KB_ROWS][KB_COLS] = {
{'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'},
{'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', LV_KEY_BACKSPACE},
{'\0', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '$', LV_KEY_ENTER},
{'\0', '\0', '\0', '\0', '\0', LV_KEY_PREV, '0', ' ', '\0', LV_KEY_NEXT}
};
// Symbol layer (SYM held). The T-Deck Max silkscreen for the symbol layer is
// not documented in the vendor sources, so these are sensible defaults; adjust
// to match the printed keys once verified on hardware.
static constexpr char keymap_sy[KB_ROWS][KB_COLS] = {
{'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'},
{'@', '#', '+', '-', '*', '/', '(', ')', '_', LV_KEY_BACKSPACE},
{'\0', '!', '?', ';', ':', '\'', '"', ',', '.', LV_KEY_ENTER},
{'\0', '\0', '\0', '\0', '\0', LV_KEY_PREV, '0', ' ', '\0', LV_KEY_NEXT}
};
void TdeckmaxKeyboard::readCallback(lv_indev_t* indev, lv_indev_data_t* data) {
auto keyboard = static_cast<TdeckmaxKeyboard*>(lv_indev_get_user_data(indev));
// Emit the release edge for the previous key first: LVGL's keypad handling
// only delivers a key on a RELEASED->PRESSED transition, so two different
// keys reported PRESSED back-to-back would swallow the second one.
if (keyboard->lastKeyNeedsRelease) {
keyboard->lastKeyNeedsRelease = false;
data->key = keyboard->lastKey;
data->state = LV_INDEV_STATE_RELEASED;
data->continue_reading = uxQueueMessagesWaiting(keyboard->queue) > 0;
return;
}
char keypress = 0;
if (xQueueReceive(keyboard->queue, &keypress, 0) == pdPASS) {
keyboard->lastKey = static_cast<uint32_t>(keypress);
keyboard->lastKeyNeedsRelease = true;
data->key = keyboard->lastKey;
data->state = LV_INDEV_STATE_PRESSED;
// Drain the whole queue in this read cycle so a typing burst lands in
// one render pass (and thus a single e-paper refresh).
data->continue_reading = true;
} else {
data->key = 0;
data->state = LV_INDEV_STATE_RELEASED;
}
}
void TdeckmaxKeyboard::processKeyboard() {
bool anykey_pressed = false;
// Each update() pops one event from the TCA8418's FIFO. Drain it fully per
// poll (bounded, in case of a misbehaving bus) so a fast typing burst isn't
// throttled to one event per poll period. Handling each event as a discrete
// press/release edge also means a key held across another key's press (fast
// typing rollover) no longer re-sends its character.
for (int drained = 0; drained < 16 && keypad->update(); drained++) {
if (keypad->released_key_count > 0) {
// Release event: only modifier state cares about releases.
auto row = keypad->released_list[0].row;
auto vcol = (KB_COLS - 1) - keypad->released_list[0].col;
if ((row == 2) && (vcol == 0)) {
shiftPressed = false; // ALT key
}
if ((row == 3) && (vcol == 8)) {
symPressed = false; // SYM key
}
} else if (keypad->pressed_key_count > 0) {
// Press event: the key that caused it is the newest list entry.
auto row = keypad->pressed_list[keypad->pressed_key_count - 1].row;
auto vcol = (KB_COLS - 1) - keypad->pressed_list[keypad->pressed_key_count - 1].col;
anykey_pressed = true;
if ((row == 2) && (vcol == 0)) {
shiftPressed = true; // ALT key
} else if ((row == 3) && (vcol == 8)) {
symPressed = true; // SYM key
} else {
char chr;
if (symPressed) {
chr = keymap_sy[row][vcol];
} else if (shiftPressed || capToggle) {
chr = keymap_uc[row][vcol];
} else {
chr = keymap_lc[row][vcol];
}
// Non-blocking: this runs on the periodic input timer, so a full
// queue (reader stalled) must drop the keystroke rather than
// stall this callback and the timer's other periodic work.
if (chr != '\0' && xQueueSend(queue, &chr, 0) != pdPASS) {
LOG_W(TAG, "Keyboard queue full, dropping keystroke");
}
}
if ((symPressed && shiftPressed) && capToggleArmed) {
capToggle = !capToggle;
capToggleArmed = false;
}
}
if ((!symPressed && !shiftPressed) && !capToggleArmed) {
capToggleArmed = true;
}
}
if (anykey_pressed) {
makeBacklightImpulse();
}
}
bool TdeckmaxKeyboard::startLvgl(lv_display_t* display) {
backlightOkay = initBacklight(BACKLIGHT, 30000, LEDC_TIMER_0, LEDC_CHANNEL_1);
keypad->init(KB_ROWS, KB_COLS);
assert(inputTimer == nullptr);
inputTimer = std::make_unique<tt::Timer>(tt::Timer::Type::Periodic, tt::kernel::millisToTicks(20), [this] {
processKeyboard();
});
assert(backlightImpulseTimer == nullptr);
backlightImpulseTimer = std::make_unique<tt::Timer>(tt::Timer::Type::Periodic, tt::kernel::millisToTicks(50), [this] {
processBacklightImpulse();
});
kbHandle = lv_indev_create();
lv_indev_set_type(kbHandle, LV_INDEV_TYPE_KEYPAD);
lv_indev_set_read_cb(kbHandle, &readCallback);
lv_indev_set_display(kbHandle, display);
lv_indev_set_user_data(kbHandle, this);
inputTimer->start();
backlightImpulseTimer->start();
return true;
}
bool TdeckmaxKeyboard::stopLvgl() {
assert(inputTimer);
inputTimer->stop();
inputTimer = nullptr;
assert(backlightImpulseTimer);
backlightImpulseTimer->stop();
backlightImpulseTimer = nullptr;
lv_indev_delete(kbHandle);
kbHandle = nullptr;
return true;
}
bool TdeckmaxKeyboard::isAttached() const {
return i2c_controller_has_device_at_address(keypad->getController(), keypad->getAddress(), 100) == ERROR_NONE;
}
bool TdeckmaxKeyboard::initBacklight(gpio_num_t pin, uint32_t frequencyHz, ledc_timer_t timer, ledc_channel_t channel) {
backlightPin = pin;
backlightTimer = timer;
backlightChannel = channel;
ledc_timer_config_t ledc_timer = {
.speed_mode = LEDC_LOW_SPEED_MODE,
.duty_resolution = LEDC_TIMER_8_BIT,
.timer_num = backlightTimer,
.freq_hz = frequencyHz,
.clk_cfg = LEDC_AUTO_CLK,
.deconfigure = false
};
if (ledc_timer_config(&ledc_timer) != ESP_OK) {
LOG_E(TAG, "Backlight timer config failed");
return false;
}
ledc_channel_config_t ledc_channel = {
.gpio_num = backlightPin,
.speed_mode = LEDC_LOW_SPEED_MODE,
.channel = backlightChannel,
.intr_type = LEDC_INTR_DISABLE,
.timer_sel = backlightTimer,
.duty = 0,
.hpoint = 0,
.sleep_mode = LEDC_SLEEP_MODE_NO_ALIVE_NO_PD,
.flags = {
.output_invert = 0
}
};
if (ledc_channel_config(&ledc_channel) != ESP_OK) {
LOG_E(TAG, "Backlight channel config failed");
return false;
}
return true;
}
bool TdeckmaxKeyboard::setBacklightDuty(uint8_t duty) {
if (!backlightOkay) {
LOG_E(TAG, "Backlight not ready");
return false;
}
return (ledc_set_duty(LEDC_LOW_SPEED_MODE, backlightChannel, duty) == ESP_OK) &&
(ledc_update_duty(LEDC_LOW_SPEED_MODE, backlightChannel) == ESP_OK);
}
void TdeckmaxKeyboard::makeBacklightImpulse() {
backlightImpulseDuty = 255;
setBacklightDuty(backlightImpulseDuty);
}
void TdeckmaxKeyboard::processBacklightImpulse() {
if (backlightImpulseDuty > 0) {
backlightImpulseDuty--;
setBacklightDuty(backlightImpulseDuty);
}
}
@@ -0,0 +1,69 @@
#pragma once
#include <Tactility/hal/keyboard/KeyboardDevice.h>
#include <Tactility/Timer.h>
#include <Tca8418.h>
#include <driver/gpio.h>
#include <driver/ledc.h>
#include <freertos/queue.h>
// QWERTY keyboard for the LilyGO T-Deck (Pro) Max.
//
// The keyboard is a TCA8418 4x10 matrix scanner on the shared I2C bus at 0x34.
// The keyboard reset line is wired through the XL9555 IO expander (P11), so it
// must be released before this device is started (see Configuration.cpp).
//
// Pins, address and keymap are taken from Xinyuan-LilyGO/T-Deck-MAX:
// examples/factory/peri_keypad.cpp and lib/TDeckMaxBoard/src/TDeckMaxBoard.h.
class TdeckmaxKeyboard final : public tt::hal::keyboard::KeyboardDevice {
lv_indev_t* kbHandle = nullptr;
gpio_num_t backlightPin = GPIO_NUM_NC;
ledc_timer_t backlightTimer;
ledc_channel_t backlightChannel;
bool backlightOkay = false;
int backlightImpulseDuty = 0;
QueueHandle_t queue = nullptr;
// LVGL only registers a key on a RELEASED->PRESSED edge, so readCallback
// alternates press/release per queued key; this tracks the pending release.
uint32_t lastKey = 0;
bool lastKeyNeedsRelease = false;
std::shared_ptr<Tca8418> keypad;
std::unique_ptr<tt::Timer> inputTimer;
std::unique_ptr<tt::Timer> backlightImpulseTimer;
bool shiftPressed = false;
bool symPressed = false;
bool capToggle = false;
bool capToggleArmed = true;
bool initBacklight(gpio_num_t pin, uint32_t frequencyHz, ledc_timer_t timer, ledc_channel_t channel);
void processKeyboard();
void processBacklightImpulse();
static void readCallback(lv_indev_t* indev, lv_indev_data_t* data);
public:
explicit TdeckmaxKeyboard(const std::shared_ptr<Tca8418>& tca) : keypad(tca) {
queue = xQueueCreate(20, sizeof(char));
}
~TdeckmaxKeyboard() override {
vQueueDelete(queue);
}
std::string getName() const override { return "T-Deck Max Keyboard"; }
std::string getDescription() const override { return "T-Deck Max TCA8418 I2C matrix keyboard"; }
bool startLvgl(lv_display_t* display) override;
bool stopLvgl() override;
bool isAttached() const override;
lv_indev_t* getLvglIndev() override { return kbHandle; }
bool setBacklightDuty(uint8_t duty);
void makeBacklightImpulse();
};
@@ -0,0 +1,23 @@
#include <tactility/module.h>
extern "C" {
static error_t start() {
// Empty for now
return ERROR_NONE;
}
static error_t stop() {
// Empty for now
return ERROR_NONE;
}
struct Module lilygo_tdeck_max_module = {
.name = "lilygo-tdeck-max",
.start = start,
.stop = stop,
.symbols = nullptr,
.internal = nullptr
};
}
@@ -0,0 +1,31 @@
general.vendor=LilyGO
general.name=T-Deck Max
# SD card support is not yet working; remove once it is functional.
general.incubating=true
apps.launcherAppId=Launcher
hardware.target=ESP32S3
hardware.flashSize=16MB
hardware.flashMode=DIO
hardware.spiRam=true
hardware.spiRamMode=AUTO
hardware.spiRamSpeed=80M
hardware.tinyUsb=true
hardware.esptoolFlashFreq=80M
hardware.bluetooth=true
# Internal until the SD card is verified working on this board; switch to SD then.
storage.userDataLocation=Internal
display.size=3.1"
display.shape=rectangle
display.dpi=128
# The GDEQ031T10 e-paper is monochrome, but its driver renders via
# LV_COLOR_FORMAT_I1 on its own display. The global LVGL color depth must stay
# at 16: esp_lvgl_port refuses to compile with LV_COLOR_DEPTH_1 set.
lvgl.colorDepth=16
# Monochrome theme: the default colour theme renders accent-coloured UI that
# thresholds to invisible on a 1bpp panel (enables CONFIG_LV_USE_THEME_MONO).
lvgl.theme=Mono
+4
View File
@@ -0,0 +1,4 @@
dependencies:
- Platforms/platform-esp32
- Drivers/xl9555-module
dts: lilygo,tdeck-max.dts
@@ -0,0 +1,58 @@
/dts-v1/;
#include <tactility/bindings/root.h>
#include <tactility/bindings/esp32_ble.h>
#include <tactility/bindings/esp32_gpio.h>
#include <tactility/bindings/esp32_i2c_master.h>
#include <tactility/bindings/esp32_spi.h>
#include <tactility/bindings/esp32_sdspi.h>
#include <bindings/xl9555.h>
// Reference: https://github.com/Xinyuan-LilyGO/T-Deck-MAX
// lib/TDeckMaxBoard/src/TDeckMaxBoard.h, docs/pinmap.md
/ {
compatible = "root";
model = "LilyGO T-Deck Max";
ble0 {
compatible = "espressif,esp32-ble";
};
gpio0 {
compatible = "espressif,esp32-gpio";
gpio-count = <49>;
};
i2c0 {
compatible = "espressif,esp32-i2c-master";
port = <I2C_NUM_0>;
clock-frequency = <100000>;
pin-sda = <&gpio0 13 GPIO_FLAG_NONE>;
pin-scl = <&gpio0 14 GPIO_FLAG_NONE>;
xl9555 {
compatible = "xlsemi,xl9555";
reg = <0x20>;
};
};
spi0 {
compatible = "espressif,esp32-spi";
host = <SPI2_HOST>;
cs-gpios = <&gpio0 34 GPIO_FLAG_NONE>, // 0: EPD display
<&gpio0 48 GPIO_FLAG_NONE>, // 1: SD card
<&gpio0 3 GPIO_FLAG_NONE>; // 2: LoRa radio (SX1262, not wired up yet)
pin-mosi = <&gpio0 33 GPIO_FLAG_NONE>;
pin-miso = <&gpio0 47 GPIO_FLAG_NONE>;
pin-sclk = <&gpio0 36 GPIO_FLAG_NONE>;
// The EPD pushes its whole 9600-byte framebuffer in one SPI write, which
// exceeds the default ~4 KB transfer limit. Raise the bus limit to fit it.
max-transfer-size = <65536>;
sdcard@1 {
compatible = "espressif,esp32-sdspi";
status = "disabled";
frequency-khz = <20000>;
};
};
};
+5
View File
@@ -0,0 +1,5 @@
idf_component_register(
SRC_DIRS "Source"
INCLUDE_DIRS "Source"
REQUIRES Tactility driver
)
+35
View File
@@ -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(&regionBuffer[n], &renderBitmap[base], widthBytes);
} else {
std::memcpy(&regionBuffer[n], &shadowFramebuffer[base], widthBytes);
}
n += widthBytes;
}
if (!writeCommand(CMD_DATA_START_OLD) || !writeData(regionBuffer.get(), n)) {
LOG_E(TAG, "Failed to send old window data");
writeCommand(CMD_PARTIAL_OUT);
return;
}
// New region: inverted render, and update the shadow for this region as we go.
n = 0;
for (int row = firstRow; row <= lastRow; row++) {
const size_t base = static_cast<size_t>(row) * BYTES_PER_ROW + firstByteCol;
for (int c = 0; c < widthBytes; c++) {
const uint8_t value = static_cast<uint8_t>(~renderBitmap[base + c]);
regionBuffer[n++] = value;
shadowFramebuffer[base + c] = value;
}
}
if (!writeCommand(CMD_DATA_START_NEW) || !writeData(regionBuffer.get(), n)) {
LOG_E(TAG, "Failed to send new window data");
writeCommand(CMD_PARTIAL_OUT);
return;
}
if (writeCommand(CMD_DISPLAY_REFRESH)) {
vTaskDelay(pdMS_TO_TICKS(1));
if (!waitWhileBusy()) {
LOG_E(TAG, "Window refresh did not complete");
}
} else {
LOG_E(TAG, "Failed to trigger window refresh");
}
writeCommand(CMD_PARTIAL_OUT);
}
void Gdeq031t10Display::flushCallback(lv_display_t* display, const lv_area_t* area, uint8_t* pixelMap) {
// pixelMap points into renderFramebuffer (it was passed directly to
// lv_display_set_buffers). Only stage the frame here: the physical refresh
// takes up to ~1s, so it runs on the driver's refresh task instead of
// blocking the LVGL task (which would stall all input handling).
auto* self = static_cast<Gdeq031t10Display*>(lv_display_get_user_data(display));
if (lv_display_flush_is_last(display)) {
self->queueRefresh();
}
lv_display_flush_ready(display);
}
void Gdeq031t10Display::themeApplyCallback(lv_theme_t* /*theme*/, lv_obj_t* obj) {
// Keep textarea cursors solid (no blink): on e-paper each blink toggle is a
// panel refresh. anim_duration 0 makes lv_textarea skip the blink animation.
if (lv_obj_check_type(obj, &lv_textarea_class)) {
lv_obj_set_style_anim_duration(obj, 0, LV_PART_CURSOR);
}
}
bool Gdeq031t10Display::start() {
if (initialized) {
return true;
}
gpio_config_t outputConfig = {
.pin_bit_mask = (1ULL << configuration->pinDc) | (1ULL << configuration->pinReset),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&outputConfig);
gpio_config_t busyConfig = {
.pin_bit_mask = 1ULL << configuration->pinBusy,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&busyConfig);
spi_device_interface_config_t deviceConfig = {
.mode = 0,
.clock_speed_hz = configuration->clockSpeedHz,
.spics_io_num = configuration->pinCs,
.queue_size = 1,
};
if (spi_bus_add_device(configuration->spiHost, &deviceConfig, &spiDevice) != ESP_OK) {
LOG_E(TAG, "Failed to add SPI device");
return false;
}
if (panelMutex == nullptr) {
panelMutex = xSemaphoreCreateMutex();
}
if (bufferMutex == nullptr) {
bufferMutex = xSemaphoreCreateMutex();
}
if (refreshTaskExited == nullptr) {
refreshTaskExited = xSemaphoreCreateBinary();
}
shadowFramebuffer = std::make_unique<uint8_t[]>(FRAMEBUFFER_SIZE);
// LVGL needs room for the 8-byte I1 palette ahead of the 1bpp bitmap.
renderFramebuffer = std::make_unique<uint8_t[]>(LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE);
// Scratch for gathering a windowed region (worst case = the whole frame).
regionBuffer = std::make_unique<uint8_t[]>(FRAMEBUFFER_SIZE);
pendingFramebuffer = std::make_unique<uint8_t[]>(FRAMEBUFFER_SIZE);
taskFramebuffer = std::make_unique<uint8_t[]>(FRAMEBUFFER_SIZE);
std::memset(shadowFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE);
std::memset(renderFramebuffer.get(), 0xFF, LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE);
std::memset(pendingFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE);
std::memset(taskFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE);
currentRefreshMode = configuration->defaultRefreshMode;
initFull();
refreshTaskShouldExit = false;
if (xTaskCreate(&refreshTaskMain, "epd_refresh", REFRESH_TASK_STACK_SIZE, this, REFRESH_TASK_PRIORITY, &refreshTask) != pdPASS) {
LOG_E(TAG, "Failed to create refresh task");
spi_bus_remove_device(spiDevice);
spiDevice = nullptr;
shadowFramebuffer.reset();
renderFramebuffer.reset();
regionBuffer.reset();
pendingFramebuffer.reset();
taskFramebuffer.reset();
return false;
}
powered = true;
initialized = true;
return true;
}
bool Gdeq031t10Display::stop() {
if (!initialized) {
return true;
}
stopLvgl();
// Join the refresh task before touching the panel: it may be mid-refresh.
refreshTaskShouldExit = true;
xTaskNotifyGive(refreshTask);
xSemaphoreTake(refreshTaskExited, portMAX_DELAY);
refreshTask = nullptr;
setPowerOn(false);
xSemaphoreTake(panelMutex, portMAX_DELAY);
initialized = false;
spi_bus_remove_device(spiDevice);
spiDevice = nullptr;
shadowFramebuffer.reset();
renderFramebuffer.reset();
regionBuffer.reset();
pendingFramebuffer.reset();
taskFramebuffer.reset();
framePending = false;
xSemaphoreGive(panelMutex);
return true;
}
void Gdeq031t10Display::setPowerOn(bool turnOn) {
if (turnOn == powered) {
return;
}
xSemaphoreTake(panelMutex, portMAX_DELAY);
if (turnOn) {
initFull(); // toggling RST also wakes the panel from deep sleep
} else {
if (panelPowerOn) {
powerOff();
}
vTaskDelay(pdMS_TO_TICKS(100));
writeCommand(CMD_DEEP_SLEEP);
writeData(DEEP_SLEEP_CHECK_CODE);
// Deep sleep needs a reset to wake, which restores register defaults.
panelMode.reset();
}
powered = turnOn;
xSemaphoreGive(panelMutex);
if (turnOn && refreshTask != nullptr) {
// initFull left the charge pump on. Kick the refresh task: it redraws
// any frame staged while the panel was off, then idles the pump down.
xTaskNotifyGive(refreshTask);
}
}
void Gdeq031t10Display::requestFullRefresh() {
forceFullRefresh = true;
}
bool Gdeq031t10Display::startLvgl() {
if (lvglDisplay != nullptr) {
return true;
}
lvglDisplay = lv_display_create(Gdeq031t10Display::WIDTH, Gdeq031t10Display::HEIGHT);
if (lvglDisplay == nullptr) {
return false;
}
lv_display_set_user_data(lvglDisplay, this);
lv_display_set_color_format(lvglDisplay, LV_COLOR_FORMAT_I1);
// The default colour theme renders accent-coloured text/icons that threshold
// to near-invisible on a 1bpp panel. Apply LVGL's monochrome theme (light
// background, dark foreground) so UI content shows as solid black on white.
lv_theme_t* baseTheme = lv_theme_mono_init(lvglDisplay, false, LV_FONT_DEFAULT);
// Chain a theme on top of the mono theme that disables the textarea cursor
// blink. A blinking cursor invalidates its region ~twice a second, and on
// e-paper every invalidation is a panel refresh, so text-entry screens (e.g.
// the Wi-Fi password field) flash continuously. The mono theme still applies
// first (it's the parent); themeApplyCallback only pins the cursor solid.
static lv_theme_t epaperTheme;
epaperTheme = *baseTheme;
lv_theme_set_parent(&epaperTheme, baseTheme);
lv_theme_set_apply_cb(&epaperTheme, &Gdeq031t10Display::themeApplyCallback);
lv_display_set_theme(lvglDisplay, &epaperTheme);
lv_display_set_render_mode(lvglDisplay, LV_DISPLAY_RENDER_MODE_FULL);
lv_display_set_buffers(lvglDisplay, renderFramebuffer.get(), nullptr, LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE, LV_DISPLAY_RENDER_MODE_FULL);
lv_display_set_flush_cb(lvglDisplay, flushCallback);
return true;
}
bool Gdeq031t10Display::stopLvgl() {
if (lvglDisplay == nullptr) {
return true;
}
lv_display_delete(lvglDisplay);
lvglDisplay = nullptr;
return true;
}
@@ -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; }
};
+11
View File
@@ -0,0 +1,11 @@
cmake_minimum_required(VERSION 3.20)
include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake")
file(GLOB_RECURSE SOURCE_FILES "source/*.c*")
tactility_add_module(xl9555-module
SRCS ${SOURCE_FILES}
INCLUDE_DIRS include/
REQUIRES TactilityKernel
)
+195
View File
@@ -0,0 +1,195 @@
Apache License
==============
_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_
### Terms and Conditions for use, reproduction, and distribution
#### 1. Definitions
“License” shall mean the terms and conditions for use, reproduction, and
distribution as defined by Sections 1 through 9 of this document.
“Licensor” shall mean the copyright owner or entity authorized by the copyright
owner that is granting the License.
“Legal Entity” shall mean the union of the acting entity and all other entities
that control, are controlled by, or are under common control with that entity.
For the purposes of this definition, “control” means **(i)** the power, direct or
indirect, to cause the direction or management of such entity, whether by
contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
outstanding shares, or **(iii)** beneficial ownership of such entity.
“You” (or “Your”) shall mean an individual or Legal Entity exercising
permissions granted by this License.
“Source” form shall mean the preferred form for making modifications, including
but not limited to software source code, documentation source, and configuration
files.
“Object” form shall mean any form resulting from mechanical transformation or
translation of a Source form, including but not limited to compiled object code,
generated documentation, and conversions to other media types.
“Work” shall mean the work of authorship, whether in Source or Object form, made
available under the License, as indicated by a copyright notice that is included
in or attached to the work (an example is provided in the Appendix below).
“Derivative Works” shall mean any work, whether in Source or Object form, that
is based on (or derived from) the Work and for which the editorial revisions,
annotations, elaborations, or other modifications represent, as a whole, an
original work of authorship. For the purposes of this License, Derivative Works
shall not include works that remain separable from, or merely link (or bind by
name) to the interfaces of, the Work and Derivative Works thereof.
“Contribution” shall mean any work of authorship, including the original version
of the Work and any modifications or additions to that Work or Derivative Works
thereof, that is intentionally submitted to Licensor for inclusion in the Work
by the copyright owner or by an individual or Legal Entity authorized to submit
on behalf of the copyright owner. For the purposes of this definition,
“submitted” means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems, and
issue tracking systems that are managed by, or on behalf of, the Licensor for
the purpose of discussing and improving the Work, but excluding communication
that is conspicuously marked or otherwise designated in writing by the copyright
owner as “Not a Contribution.”
“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
of whom a Contribution has been received by Licensor and subsequently
incorporated within the Work.
#### 2. Grant of Copyright License
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the Work and such
Derivative Works in Source or Object form.
#### 3. Grant of Patent License
Subject to the terms and conditions of this License, each Contributor hereby
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
irrevocable (except as stated in this section) patent license to make, have
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
such license applies only to those patent claims licensable by such Contributor
that are necessarily infringed by their Contribution(s) alone or by combination
of their Contribution(s) with the Work to which such Contribution(s) was
submitted. If You institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
Contribution incorporated within the Work constitutes direct or contributory
patent infringement, then any patent licenses granted to You under this License
for that Work shall terminate as of the date such litigation is filed.
#### 4. Redistribution
You may reproduce and distribute copies of the Work or Derivative Works thereof
in any medium, with or without modifications, and in Source or Object form,
provided that You meet the following conditions:
* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
this License; and
* **(b)** You must cause any modified files to carry prominent notices stating that You
changed the files; and
* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
all copyright, patent, trademark, and attribution notices from the Source form
of the Work, excluding those notices that do not pertain to any part of the
Derivative Works; and
* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
Derivative Works that You distribute must include a readable copy of the
attribution notices contained within such NOTICE file, excluding those notices
that do not pertain to any part of the Derivative Works, in at least one of the
following places: within a NOTICE text file distributed as part of the
Derivative Works; within the Source form or documentation, if provided along
with the Derivative Works; or, within a display generated by the Derivative
Works, if and wherever such third-party notices normally appear. The contents of
the NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative Works that
You distribute, alongside or as an addendum to the NOTICE text from the Work,
provided that such additional attribution notices cannot be construed as
modifying the License.
You may add Your own copyright statement to Your modifications and may provide
additional or different license terms and conditions for use, reproduction, or
distribution of Your modifications, or for any such Derivative Works as a whole,
provided Your use, reproduction, and distribution of the Work otherwise complies
with the conditions stated in this License.
#### 5. Submission of Contributions
Unless You explicitly state otherwise, any Contribution intentionally submitted
for inclusion in the Work by You to the Licensor shall be under the terms and
conditions of this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify the terms of
any separate license agreement you may have executed with Licensor regarding
such Contributions.
#### 6. Trademarks
This License does not grant permission to use the trade names, trademarks,
service marks, or product names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the NOTICE file.
#### 7. Disclaimer of Warranty
Unless required by applicable law or agreed to in writing, Licensor provides the
Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
including, without limitation, any warranties or conditions of TITLE,
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
solely responsible for determining the appropriateness of using or
redistributing the Work and assume any risks associated with Your exercise of
permissions under this License.
#### 8. Limitation of Liability
In no event and under no legal theory, whether in tort (including negligence),
contract, or otherwise, unless required by applicable law (such as deliberate
and grossly negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special, incidental,
or consequential damages of any character arising as a result of this License or
out of the use or inability to use the Work (including but not limited to
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
any and all other commercial damages or losses), even if such Contributor has
been advised of the possibility of such damages.
#### 9. Accepting Warranty or Additional Liability
While redistributing the Work or Derivative Works thereof, You may choose to
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
other liability obligations and/or rights consistent with this License. However,
in accepting such obligations, You may act only on Your own behalf and on Your
sole responsibility, not on behalf of any other Contributor, and only if You
agree to indemnify, defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason of your
accepting any such warranty or additional liability.
_END OF TERMS AND CONDITIONS_
### APPENDIX: How to apply the Apache License to your work
To apply the Apache License to your work, attach the following boilerplate
notice, with the fields enclosed by brackets `[]` replaced with your own
identifying information. (Don't include the brackets!) The text should be
enclosed in the appropriate comment syntax for the file format. We also
recommend that a file or class name and description of purpose be included on
the same “printed page” as the copyright notice for easier identification within
third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+11
View File
@@ -0,0 +1,11 @@
# 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"
+3
View File
@@ -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
+32
View File
@@ -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
};
}
+194
View File
@@ -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
};
}