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