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
+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>;
};
};
};