diff --git a/Devices/lilygo-tdeck-max/CMakeLists.txt b/Devices/lilygo-tdeck-max/CMakeLists.txt new file mode 100644 index 000000000..b241604b0 --- /dev/null +++ b/Devices/lilygo-tdeck-max/CMakeLists.txt @@ -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 +) diff --git a/Devices/lilygo-tdeck-max/Source/Configuration.cpp b/Devices/lilygo-tdeck-max/Source/Configuration.cpp new file mode 100644 index 000000000..703b896bd --- /dev/null +++ b/Devices/lilygo-tdeck-max/Source/Configuration.cpp @@ -0,0 +1,101 @@ +#include "devices/Display.h" +#include "devices/TdeckmaxKeyboard.h" + +#include + +#include +#include +#include +#include +#include + +using namespace tt::hal; + +// LoRa and SD card share the EPD's SPI bus but aren't wired up yet, so their +// chip-select lines are left floating. Deassert them before the EPD driver +// touches the bus, matching the vendor reference driver's own setup(), so a +// floating CS can't make either chip latch EPD command bytes meant for it. +constexpr auto LORA_PIN_CS = GPIO_NUM_3; +constexpr auto SD_PIN_CS = GPIO_NUM_48; + +// 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() { + gpio_config_t config = { + .pin_bit_mask = (1ULL << LORA_PIN_CS) | (1ULL << SD_PIN_CS), + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + gpio_config(&config); + gpio_set_level(LORA_PIN_CS, 1); + gpio_set_level(SD_PIN_CS, 1); + + 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(i2c); + devices.push_back(keypad); + devices.push_back(std::make_shared(keypad)); + + return devices; +} + +extern const Configuration hardwareConfiguration = { + .initBoot = initBoot, + .createDevices = createDevices +}; diff --git a/Devices/lilygo-tdeck-max/Source/devices/Cst66xxTouch.cpp b/Devices/lilygo-tdeck-max/Source/devices/Cst66xxTouch.cpp new file mode 100644 index 000000000..5e0d48b94 --- /dev/null +++ b/Devices/lilygo-tdeck-max/Source/devices/Cst66xxTouch.cpp @@ -0,0 +1,225 @@ +#include "Cst66xxTouch.h" + +#include +#include +#include +#include + +#include + +// 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(buf[index + 4] | (static_cast(buf[index + 7] & 0x0F) << 8)); + int16_t rawY = static_cast(buf[index + 5] | (static_cast(buf[index + 7] & 0xF0) << 4)); + + if (configuration.swapXy) { + std::swap(rawX, rawY); + } + if (configuration.mirrorX) { + rawX = static_cast(configuration.width - 1 - rawX); + } + if (configuration.mirrorY) { + rawY = static_cast(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(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; +} diff --git a/Devices/lilygo-tdeck-max/Source/devices/Cst66xxTouch.h b/Devices/lilygo-tdeck-max/Source/devices/Cst66xxTouch.h new file mode 100644 index 000000000..5a17fb04f --- /dev/null +++ b/Devices/lilygo-tdeck-max/Source/devices/Cst66xxTouch.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +// 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 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); +}; diff --git a/Devices/lilygo-tdeck-max/Source/devices/Display.cpp b/Devices/lilygo-tdeck-max/Source/devices/Display.cpp new file mode 100644 index 000000000..2b9e99506 --- /dev/null +++ b/Devices/lilygo-tdeck-max/Source/devices/Display.cpp @@ -0,0 +1,57 @@ +#include "Display.h" +#include "Cst66xxTouch.h" + +#include +#include +#include + +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 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(configuration); +} + +std::shared_ptr createDisplay() { + auto configuration = std::make_unique( + 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(std::move(configuration)); +} diff --git a/Devices/lilygo-tdeck-max/Source/devices/Display.h b/Devices/lilygo-tdeck-max/Source/devices/Display.h new file mode 100644 index 000000000..7a9b967d1 --- /dev/null +++ b/Devices/lilygo-tdeck-max/Source/devices/Display.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +std::shared_ptr createDisplay(); diff --git a/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.cpp b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.cpp new file mode 100644 index 000000000..e40b6a604 --- /dev/null +++ b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.cpp @@ -0,0 +1,249 @@ +#include "TdeckmaxKeyboard.h" + +#include + +#include +#include + +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(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(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::Type::Periodic, tt::kernel::millisToTicks(20), [this] { + processKeyboard(); + }); + + assert(backlightImpulseTimer == nullptr); + backlightImpulseTimer = std::make_unique(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); + } +} diff --git a/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.h b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.h new file mode 100644 index 000000000..4ff9079a4 --- /dev/null +++ b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +// 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 keypad; + std::unique_ptr inputTimer; + std::unique_ptr 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& 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(); +}; diff --git a/Devices/lilygo-tdeck-max/Source/module.cpp b/Devices/lilygo-tdeck-max/Source/module.cpp new file mode 100644 index 000000000..c9ffbeb82 --- /dev/null +++ b/Devices/lilygo-tdeck-max/Source/module.cpp @@ -0,0 +1,23 @@ +#include + +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 +}; + +} diff --git a/Devices/lilygo-tdeck-max/device.properties b/Devices/lilygo-tdeck-max/device.properties new file mode 100644 index 000000000..9fab342db --- /dev/null +++ b/Devices/lilygo-tdeck-max/device.properties @@ -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 diff --git a/Devices/lilygo-tdeck-max/devicetree.yaml b/Devices/lilygo-tdeck-max/devicetree.yaml new file mode 100644 index 000000000..07314c274 --- /dev/null +++ b/Devices/lilygo-tdeck-max/devicetree.yaml @@ -0,0 +1,4 @@ +dependencies: + - Platforms/platform-esp32 + - Drivers/xl9555-module +dts: lilygo,tdeck-max.dts diff --git a/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts b/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts new file mode 100644 index 000000000..bdccfce94 --- /dev/null +++ b/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts @@ -0,0 +1,58 @@ +/dts-v1/; + +#include +#include +#include +#include +#include +#include +#include + +// 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 = ; + 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 = ; + 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>; + }; + }; +}; diff --git a/Drivers/GDEQ031T10/CMakeLists.txt b/Drivers/GDEQ031T10/CMakeLists.txt new file mode 100644 index 000000000..f604c373c --- /dev/null +++ b/Drivers/GDEQ031T10/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "Source" + INCLUDE_DIRS "Source" + REQUIRES Tactility driver +) diff --git a/Drivers/GDEQ031T10/README.md b/Drivers/GDEQ031T10/README.md new file mode 100644 index 000000000..2896e84b4 --- /dev/null +++ b/Drivers/GDEQ031T10/README.md @@ -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. diff --git a/Drivers/GDEQ031T10/Source/Gdeq031t10Display.cpp b/Drivers/GDEQ031T10/Source/Gdeq031t10Display.cpp new file mode 100644 index 000000000..e900101e3 --- /dev/null +++ b/Drivers/GDEQ031T10/Source/Gdeq031t10Display.cpp @@ -0,0 +1,638 @@ +#include "Gdeq031t10Display.h" + +#include + +#include +#include +#include +#include +#include +// Full lv_theme_t definition (opaque in public lvgl.h) — needed to extend the +// mono theme with a custom apply callback, per LVGL's theme-extension pattern. +#include + +constexpr auto* TAG = "GDEQ031T10"; + +// UC8253-family commands, ported from Xinyuan-LilyGO/T-Deck-MAX's +// Display_EPD_W21.cpp reference driver. +namespace { +constexpr uint8_t CMD_PANEL_SETTING = 0x00; +constexpr uint8_t CMD_POWER_ON_OFF = 0x02; // shared opcode: power on when followed by 0x04, power off as standalone 0x02 +constexpr uint8_t CMD_POWER_ON = 0x04; +constexpr uint8_t CMD_DEEP_SLEEP = 0x07; +constexpr uint8_t CMD_DATA_START_OLD = 0x10; +constexpr uint8_t CMD_DISPLAY_REFRESH = 0x12; +constexpr uint8_t CMD_DATA_START_NEW = 0x13; +constexpr uint8_t CMD_VCOM_DATA_INTERVAL = 0x50; +constexpr uint8_t CMD_PARTIAL_WINDOW = 0x90; +constexpr uint8_t CMD_PARTIAL_IN = 0x91; +constexpr uint8_t CMD_PARTIAL_OUT = 0x92; +constexpr uint8_t CMD_FAST_MODE_ENABLE = 0xE0; +constexpr uint8_t CMD_FAST_MODE_TIMING = 0xE5; + +constexpr uint8_t DEEP_SLEEP_CHECK_CODE = 0xA5; +} + +bool Gdeq031t10Display::writeCommand(uint8_t command) { + gpio_set_level(configuration->pinDc, 0); + spi_transaction_t transaction = {}; + transaction.length = 8; + transaction.tx_buffer = &command; + if (spi_device_polling_transmit(spiDevice, &transaction) != ESP_OK) { + LOG_E(TAG, "SPI command transfer failed"); + return false; + } + return true; +} + +bool Gdeq031t10Display::writeData(const uint8_t* data, size_t length) { + gpio_set_level(configuration->pinDc, 1); + spi_transaction_t transaction = {}; + transaction.length = length * 8; + transaction.tx_buffer = data; + if (spi_device_polling_transmit(spiDevice, &transaction) != ESP_OK) { + LOG_E(TAG, "SPI data transfer failed"); + return false; + } + return true; +} + +bool Gdeq031t10Display::waitWhileBusy() const { + // BUSY pin reads high when the controller is idle/ready. + constexpr TickType_t timeout = pdMS_TO_TICKS(5000); + const TickType_t start = xTaskGetTickCount(); + while (gpio_get_level(configuration->pinBusy) != 1) { + if (xTaskGetTickCount() - start > timeout) { + LOG_E(TAG, "Timed out waiting for panel BUSY"); + return false; + } + vTaskDelay(pdMS_TO_TICKS(2)); + } + return true; +} + +void Gdeq031t10Display::reset() const { + gpio_set_level(configuration->pinReset, 0); + vTaskDelay(pdMS_TO_TICKS(10)); + gpio_set_level(configuration->pinReset, 1); + vTaskDelay(pdMS_TO_TICKS(10)); +} + +bool Gdeq031t10Display::initFull() { + reset(); + bool ok = writeCommand(CMD_PANEL_SETTING); + ok = ok && writeData(configuration->mirror180 ? 0x13 : 0x1F); + ok = ok && writeCommand(CMD_POWER_ON); + ok = ok && waitWhileBusy(); + if (!ok) { + LOG_E(TAG, "Full init failed"); + return false; + } + panelMode = RefreshMode::Full; + panelPowerOn = true; + return true; +} + +bool Gdeq031t10Display::initFast() { + if (!initFull()) { + return false; + } + bool ok = writeCommand(CMD_FAST_MODE_ENABLE); + ok = ok && writeData(0x02); + ok = ok && writeCommand(CMD_FAST_MODE_TIMING); + ok = ok && writeData(0x5A); // ~1.0s + if (!ok) { + LOG_E(TAG, "Fast mode init failed"); + return false; + } + panelMode = RefreshMode::Fast; + return true; +} + +bool Gdeq031t10Display::initSlow() { + if (!initFull()) { + return false; + } + bool ok = writeCommand(CMD_FAST_MODE_ENABLE); + ok = ok && writeData(0x02); + ok = ok && writeCommand(CMD_FAST_MODE_TIMING); + ok = ok && writeData(0x6E); // ~1.5s + if (!ok) { + LOG_E(TAG, "Slow mode init failed"); + return false; + } + panelMode = RefreshMode::Slow; + return true; +} + +bool Gdeq031t10Display::initPartial() { + if (!initFull()) { + return false; + } + bool ok = writeCommand(CMD_FAST_MODE_ENABLE); + ok = ok && writeData(0x02); + ok = ok && writeCommand(CMD_FAST_MODE_TIMING); + ok = ok && writeData(0x79); + ok = ok && writeCommand(CMD_VCOM_DATA_INTERVAL); + ok = ok && writeData(0xD7); + if (!ok) { + LOG_E(TAG, "Partial mode init failed"); + return false; + } + panelMode = RefreshMode::Partial; + return true; +} + +bool Gdeq031t10Display::ensurePanelReady(RefreshMode mode) { + if (panelMode != mode) { + // A mode change needs the full init path: it starts with reset(), which + // restores register defaults (required when leaving partial mode, whose + // VCOM/data-interval setting would otherwise linger). + switch (mode) { + case RefreshMode::Full: return initFull(); + case RefreshMode::Fast: return initFast(); + case RefreshMode::Slow: return initSlow(); + case RefreshMode::Partial: return initPartial(); + } + return false; + } else if (!panelPowerOn) { + // Registers still hold the mode; only the charge pump was idled. + if (!writeCommand(CMD_POWER_ON) || !waitWhileBusy()) { + LOG_E(TAG, "Panel did not become ready after power-on"); + return false; + } + panelPowerOn = true; + } + return true; +} + +bool Gdeq031t10Display::powerOff() { + // Command the panel off regardless of whether BUSY confirms it: retrying + // forever here would just as likely hang, and a stuck-BUSY panel is + // already unusable either way. + bool ok = writeCommand(CMD_POWER_ON_OFF); // 0x02 standalone = power off + if (!ok || !waitWhileBusy()) { + LOG_E(TAG, "Panel did not confirm power-off"); + ok = false; + } + panelPowerOn = false; + return ok; +} + +void Gdeq031t10Display::queueRefresh() { + xSemaphoreTake(bufferMutex, portMAX_DELAY); + // renderFramebuffer always holds a complete frame (single buffer, full + // render mode), so the staged copy is self-contained. + std::memcpy(pendingFramebuffer.get(), renderFramebuffer.get() + LVGL_I1_PALETTE_SIZE, FRAMEBUFFER_SIZE); + framePending = true; + xSemaphoreGive(bufferMutex); + xTaskNotifyGive(refreshTask); +} + +void Gdeq031t10Display::refreshTaskMain(void* parameter) { + auto* self = static_cast(parameter); + self->runRefreshTask(); + xSemaphoreGive(self->refreshTaskExited); + vTaskDelete(nullptr); +} + +void Gdeq031t10Display::runRefreshTask() { + while (true) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + if (refreshTaskShouldExit) { + return; + } + + // Drain staged frames latest-wins: everything LVGL flushed while the + // previous refresh was in progress collapses into one panel update. + // A frame staged while the panel is off stays staged; the power-on + // path kicks this task to draw it. + while (!refreshTaskShouldExit && powered) { + xSemaphoreTake(bufferMutex, portMAX_DELAY); + const bool havePending = framePending; + if (havePending) { + std::swap(pendingFramebuffer, taskFramebuffer); + framePending = false; + } + xSemaphoreGive(bufferMutex); + + if (!havePending) { + break; + } + refresh(); + } + + // The pipeline went idle: drop the charge pump now rather than on a + // timer. The mode registers survive a power-off, so the next refresh + // only pays a short power-on wait (on this task, invisible to the UI). + xSemaphoreTake(panelMutex, portMAX_DELAY); + if (initialized && powered && panelPowerOn) { + powerOff(); + } + xSemaphoreGive(panelMutex); + } +} + +void Gdeq031t10Display::refresh() { + const uint8_t* renderBitmap = taskFramebuffer.get(); + constexpr int BYTES_PER_ROW = WIDTH / 8; + + // Find the bounding box of bytes that differ from what the panel holds. The + // panel stores the inverted (shadow) polarity, so compare against ~render. + int firstCol = BYTES_PER_ROW, lastCol = -1, firstRow = HEIGHT, lastRow = -1; + for (int row = 0; row < HEIGHT; row++) { + const size_t base = static_cast(row) * BYTES_PER_ROW; + for (int col = 0; col < BYTES_PER_ROW; col++) { + if (static_cast(~renderBitmap[base + col]) != shadowFramebuffer[base + col]) { + if (col < firstCol) firstCol = col; + if (col > lastCol) lastCol = col; + if (row < firstRow) firstRow = row; + if (row > lastRow) lastRow = row; + } + } + } + + const bool nothingChanged = (lastCol < 0); + if (nothingChanged && !forceFullRefresh) { + return; // panel already shows this frame; don't refresh needlessly + } + + // A change covering most of the screen or an explicit request warrants a + // full refresh; otherwise refresh just the box. + const int changedRows = nothingChanged ? 0 : (lastRow - firstRow + 1); + const bool largeChange = changedRows > (HEIGHT * 3 / 4); + + xSemaphoreTake(panelMutex, portMAX_DELAY); + if (!powered || !initialized) { + // The display was turned off/stopped while this frame was staged; the + // shadow mismatch makes the frame redraw after the next power-on. + xSemaphoreGive(panelMutex); + return; + } + if (forceFullRefresh || largeChange || nothingChanged) { + // Explicit requests get the best-quality LUT; automatic ghost-clears use + // the configured (typically faster) mode. + refreshFull(forceFullRefresh ? RefreshMode::Full : currentRefreshMode.load()); + forceFullRefresh = false; + partialRefreshCount = 0; + ghostAreaValid = false; + xSemaphoreGive(panelMutex); + return; + } + + // Grow the ghost union with this change so the gate sees where partial + // updates have accumulated since the last clear. + if (!ghostAreaValid) { + ghostFirstCol = firstCol; + ghostLastCol = lastCol; + ghostFirstRow = firstRow; + ghostLastRow = lastRow; + ghostAreaValid = true; + } else { + ghostFirstCol = std::min(ghostFirstCol, firstCol); + ghostLastCol = std::max(ghostLastCol, lastCol); + ghostFirstRow = std::min(ghostFirstRow, firstRow); + ghostLastRow = std::max(ghostLastRow, lastRow); + } + + if (partialRefreshCount >= MAX_PARTIAL_REFRESHES) { + // Ghost-clear gate. If the accumulated churn is confined to a small + // region (a blinking cursor, a line being typed into), scrub just that + // window — a full-screen flash there is needless and distracting. Only + // widespread churn earns a whole-panel refresh. + const int unionWidth = (ghostLastCol - ghostFirstCol + 1) * 8; + const int unionHeight = ghostLastRow - ghostFirstRow + 1; + const bool unionIsLarge = unionWidth * unionHeight * 4 >= WIDTH * HEIGHT; // >= 25% of the panel + if (unionIsLarge) { + refreshFull(currentRefreshMode); + } else { + refreshWindow(ghostFirstCol, ghostLastCol, ghostFirstRow, ghostLastRow, true); + } + partialRefreshCount = 0; + ghostAreaValid = false; + } else { + refreshWindow(firstCol, lastCol, firstRow, lastRow, false); + partialRefreshCount++; + } + xSemaphoreGive(panelMutex); +} + +void Gdeq031t10Display::refreshFull(RefreshMode mode) { + if (!ensurePanelReady(mode)) { + LOG_E(TAG, "Skipping full refresh: panel not ready"); + return; + } + + const uint8_t* renderBitmap = taskFramebuffer.get(); + + // shadowFramebuffer keeps the panel-polarity copy of the last frame for the + // controller's old/new differential refresh; LVGL's I1 polarity is inverted. + if (!writeCommand(CMD_DATA_START_OLD) || !writeData(shadowFramebuffer.get(), FRAMEBUFFER_SIZE)) { + LOG_E(TAG, "Failed to send old frame data"); + return; + } + + for (size_t i = 0; i < FRAMEBUFFER_SIZE; i++) { + shadowFramebuffer[i] = static_cast(~renderBitmap[i]); + } + if (!writeCommand(CMD_DATA_START_NEW) || !writeData(shadowFramebuffer.get(), FRAMEBUFFER_SIZE)) { + LOG_E(TAG, "Failed to send new frame data"); + return; + } + + if (!writeCommand(CMD_DISPLAY_REFRESH)) { + LOG_E(TAG, "Failed to trigger display refresh"); + return; + } + vTaskDelay(pdMS_TO_TICKS(1)); // datasheet requires >=200us settle before polling BUSY + if (!waitWhileBusy()) { + LOG_E(TAG, "Full refresh did not complete"); + } +} + +void Gdeq031t10Display::refreshWindow(int firstByteCol, int lastByteCol, int firstRow, int lastRow, bool scrubGhosts) { + // Partial LUT (fast waveform via temperature force) on a sub-region only. The + // RAM window must be byte-aligned in X, which it already is (byte columns). + if (!ensurePanelReady(RefreshMode::Partial)) { + LOG_E(TAG, "Skipping window refresh: panel not ready"); + return; + } + + const uint8_t* renderBitmap = taskFramebuffer.get(); + constexpr int BYTES_PER_ROW = WIDTH / 8; + const int widthBytes = lastByteCol - firstByteCol + 1; + + const uint16_t x = static_cast(firstByteCol * 8); + const uint16_t xe = static_cast(lastByteCol * 8 + 7); + const uint16_t y = static_cast(firstRow); + const uint16_t ye = static_cast(lastRow); + + // Set the partial RAM window (GxEPD2 GDEQ031T10 sequence). + bool ok = writeCommand(CMD_PARTIAL_IN); + ok = ok && writeCommand(CMD_PARTIAL_WINDOW); + ok = ok && writeData(static_cast(x)); + ok = ok && writeData(static_cast(xe)); + ok = ok && writeData(static_cast(y >> 8)); + ok = ok && writeData(static_cast(y & 0xFF)); + ok = ok && writeData(static_cast(ye >> 8)); + ok = ok && writeData(static_cast(ye & 0xFF)); + ok = ok && writeData(0x01); + if (!ok) { + LOG_E(TAG, "Failed to set partial refresh window"); + writeCommand(CMD_PARTIAL_OUT); // best-effort: leave partial-window mode + return; + } + + // Old region: normally the region's current panel contents (shadow), + // gathered contiguously. For a ghost scrub, feed the complement of the new + // data instead: the controller then sees every pixel as changed and drives + // each one through a transition, clearing accumulated partial-refresh + // ghosting in this window only. + size_t n = 0; + for (int row = firstRow; row <= lastRow; row++) { + const size_t base = static_cast(row) * BYTES_PER_ROW + firstByteCol; + if (scrubGhosts) { + // New panel data is ~renderBitmap, so its complement is renderBitmap. + std::memcpy(®ionBuffer[n], &renderBitmap[base], widthBytes); + } else { + std::memcpy(®ionBuffer[n], &shadowFramebuffer[base], widthBytes); + } + n += widthBytes; + } + if (!writeCommand(CMD_DATA_START_OLD) || !writeData(regionBuffer.get(), n)) { + LOG_E(TAG, "Failed to send old window data"); + writeCommand(CMD_PARTIAL_OUT); + return; + } + + // New region: inverted render, and update the shadow for this region as we go. + n = 0; + for (int row = firstRow; row <= lastRow; row++) { + const size_t base = static_cast(row) * BYTES_PER_ROW + firstByteCol; + for (int c = 0; c < widthBytes; c++) { + const uint8_t value = static_cast(~renderBitmap[base + c]); + regionBuffer[n++] = value; + shadowFramebuffer[base + c] = value; + } + } + if (!writeCommand(CMD_DATA_START_NEW) || !writeData(regionBuffer.get(), n)) { + LOG_E(TAG, "Failed to send new window data"); + writeCommand(CMD_PARTIAL_OUT); + return; + } + + if (writeCommand(CMD_DISPLAY_REFRESH)) { + vTaskDelay(pdMS_TO_TICKS(1)); + if (!waitWhileBusy()) { + LOG_E(TAG, "Window refresh did not complete"); + } + } else { + LOG_E(TAG, "Failed to trigger window refresh"); + } + writeCommand(CMD_PARTIAL_OUT); +} + +void Gdeq031t10Display::flushCallback(lv_display_t* display, const lv_area_t* area, uint8_t* pixelMap) { + // pixelMap points into renderFramebuffer (it was passed directly to + // lv_display_set_buffers). Only stage the frame here: the physical refresh + // takes up to ~1s, so it runs on the driver's refresh task instead of + // blocking the LVGL task (which would stall all input handling). + auto* self = static_cast(lv_display_get_user_data(display)); + + if (lv_display_flush_is_last(display)) { + self->queueRefresh(); + } + + lv_display_flush_ready(display); +} + +void Gdeq031t10Display::themeApplyCallback(lv_theme_t* /*theme*/, lv_obj_t* obj) { + // Keep textarea cursors solid (no blink): on e-paper each blink toggle is a + // panel refresh. anim_duration 0 makes lv_textarea skip the blink animation. + if (lv_obj_check_type(obj, &lv_textarea_class)) { + lv_obj_set_style_anim_duration(obj, 0, LV_PART_CURSOR); + } +} + +bool Gdeq031t10Display::start() { + if (initialized) { + return true; + } + + gpio_config_t outputConfig = { + .pin_bit_mask = (1ULL << configuration->pinDc) | (1ULL << configuration->pinReset), + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + gpio_config(&outputConfig); + + gpio_config_t busyConfig = { + .pin_bit_mask = 1ULL << configuration->pinBusy, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + gpio_config(&busyConfig); + + spi_device_interface_config_t deviceConfig = { + .mode = 0, + .clock_speed_hz = configuration->clockSpeedHz, + .spics_io_num = configuration->pinCs, + .queue_size = 1, + }; + + if (spi_bus_add_device(configuration->spiHost, &deviceConfig, &spiDevice) != ESP_OK) { + LOG_E(TAG, "Failed to add SPI device"); + return false; + } + + if (panelMutex == nullptr) { + panelMutex = xSemaphoreCreateMutex(); + } + if (bufferMutex == nullptr) { + bufferMutex = xSemaphoreCreateMutex(); + } + if (refreshTaskExited == nullptr) { + refreshTaskExited = xSemaphoreCreateBinary(); + } + + shadowFramebuffer = std::make_unique(FRAMEBUFFER_SIZE); + // LVGL needs room for the 8-byte I1 palette ahead of the 1bpp bitmap. + renderFramebuffer = std::make_unique(LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE); + // Scratch for gathering a windowed region (worst case = the whole frame). + regionBuffer = std::make_unique(FRAMEBUFFER_SIZE); + pendingFramebuffer = std::make_unique(FRAMEBUFFER_SIZE); + taskFramebuffer = std::make_unique(FRAMEBUFFER_SIZE); + std::memset(shadowFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE); + std::memset(renderFramebuffer.get(), 0xFF, LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE); + std::memset(pendingFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE); + std::memset(taskFramebuffer.get(), 0xFF, FRAMEBUFFER_SIZE); + + currentRefreshMode = configuration->defaultRefreshMode; + initFull(); + + refreshTaskShouldExit = false; + if (xTaskCreate(&refreshTaskMain, "epd_refresh", REFRESH_TASK_STACK_SIZE, this, REFRESH_TASK_PRIORITY, &refreshTask) != pdPASS) { + LOG_E(TAG, "Failed to create refresh task"); + spi_bus_remove_device(spiDevice); + spiDevice = nullptr; + shadowFramebuffer.reset(); + renderFramebuffer.reset(); + regionBuffer.reset(); + pendingFramebuffer.reset(); + taskFramebuffer.reset(); + return false; + } + powered = true; + initialized = true; + return true; +} + +bool Gdeq031t10Display::stop() { + if (!initialized) { + return true; + } + + stopLvgl(); + + // Join the refresh task before touching the panel: it may be mid-refresh. + refreshTaskShouldExit = true; + xTaskNotifyGive(refreshTask); + xSemaphoreTake(refreshTaskExited, portMAX_DELAY); + refreshTask = nullptr; + + setPowerOn(false); + + xSemaphoreTake(panelMutex, portMAX_DELAY); + initialized = false; + spi_bus_remove_device(spiDevice); + spiDevice = nullptr; + shadowFramebuffer.reset(); + renderFramebuffer.reset(); + regionBuffer.reset(); + pendingFramebuffer.reset(); + taskFramebuffer.reset(); + framePending = false; + xSemaphoreGive(panelMutex); + return true; +} + +void Gdeq031t10Display::setPowerOn(bool turnOn) { + if (turnOn == powered) { + return; + } + + xSemaphoreTake(panelMutex, portMAX_DELAY); + if (turnOn) { + initFull(); // toggling RST also wakes the panel from deep sleep + } else { + if (panelPowerOn) { + powerOff(); + } + vTaskDelay(pdMS_TO_TICKS(100)); + writeCommand(CMD_DEEP_SLEEP); + writeData(DEEP_SLEEP_CHECK_CODE); + // Deep sleep needs a reset to wake, which restores register defaults. + panelMode.reset(); + } + + powered = turnOn; + xSemaphoreGive(panelMutex); + + if (turnOn && refreshTask != nullptr) { + // initFull left the charge pump on. Kick the refresh task: it redraws + // any frame staged while the panel was off, then idles the pump down. + xTaskNotifyGive(refreshTask); + } +} + +void Gdeq031t10Display::requestFullRefresh() { + forceFullRefresh = true; +} + +bool Gdeq031t10Display::startLvgl() { + if (lvglDisplay != nullptr) { + return true; + } + + lvglDisplay = lv_display_create(Gdeq031t10Display::WIDTH, Gdeq031t10Display::HEIGHT); + if (lvglDisplay == nullptr) { + return false; + } + + lv_display_set_user_data(lvglDisplay, this); + lv_display_set_color_format(lvglDisplay, LV_COLOR_FORMAT_I1); + + // The default colour theme renders accent-coloured text/icons that threshold + // to near-invisible on a 1bpp panel. Apply LVGL's monochrome theme (light + // background, dark foreground) so UI content shows as solid black on white. + lv_theme_t* baseTheme = lv_theme_mono_init(lvglDisplay, false, LV_FONT_DEFAULT); + // Chain a theme on top of the mono theme that disables the textarea cursor + // blink. A blinking cursor invalidates its region ~twice a second, and on + // e-paper every invalidation is a panel refresh, so text-entry screens (e.g. + // the Wi-Fi password field) flash continuously. The mono theme still applies + // first (it's the parent); themeApplyCallback only pins the cursor solid. + static lv_theme_t epaperTheme; + epaperTheme = *baseTheme; + lv_theme_set_parent(&epaperTheme, baseTheme); + lv_theme_set_apply_cb(&epaperTheme, &Gdeq031t10Display::themeApplyCallback); + lv_display_set_theme(lvglDisplay, &epaperTheme); + lv_display_set_render_mode(lvglDisplay, LV_DISPLAY_RENDER_MODE_FULL); + lv_display_set_buffers(lvglDisplay, renderFramebuffer.get(), nullptr, LVGL_I1_PALETTE_SIZE + FRAMEBUFFER_SIZE, LV_DISPLAY_RENDER_MODE_FULL); + lv_display_set_flush_cb(lvglDisplay, flushCallback); + + return true; +} + +bool Gdeq031t10Display::stopLvgl() { + if (lvglDisplay == nullptr) { + return true; + } + + lv_display_delete(lvglDisplay); + lvglDisplay = nullptr; + return true; +} diff --git a/Drivers/GDEQ031T10/Source/Gdeq031t10Display.h b/Drivers/GDEQ031T10/Source/Gdeq031t10Display.h new file mode 100644 index 000000000..691aa1ced --- /dev/null +++ b/Drivers/GDEQ031T10/Source/Gdeq031t10Display.h @@ -0,0 +1,225 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * 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 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 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; + 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 shadowFramebuffer; + /** Render target for LVGL; copied to the panel on flush */ + std::unique_ptr renderFramebuffer; + bool initialized = false; + std::atomic powered = false; + std::atomic 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 forceFullRefresh = true; + /** Scratch buffer used to gather a windowed region's bytes for one SPI write. */ + std::unique_ptr 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 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 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 taskFramebuffer; + bool framePending = false; + std::atomic 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 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 _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 _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; } +}; diff --git a/Drivers/xl9555-module/CMakeLists.txt b/Drivers/xl9555-module/CMakeLists.txt new file mode 100644 index 000000000..e65125815 --- /dev/null +++ b/Drivers/xl9555-module/CMakeLists.txt @@ -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 +) diff --git a/Drivers/xl9555-module/LICENSE-Apache-2.0.md b/Drivers/xl9555-module/LICENSE-Apache-2.0.md new file mode 100644 index 000000000..f5f4b8b5e --- /dev/null +++ b/Drivers/xl9555-module/LICENSE-Apache-2.0.md @@ -0,0 +1,195 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### 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. + diff --git a/Drivers/xl9555-module/README.md b/Drivers/xl9555-module/README.md new file mode 100644 index 000000000..407174ec2 --- /dev/null +++ b/Drivers/xl9555-module/README.md @@ -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) diff --git a/Drivers/xl9555-module/bindings/xlsemi,xl9555.yaml b/Drivers/xl9555-module/bindings/xlsemi,xl9555.yaml new file mode 100644 index 000000000..a93e2b6ee --- /dev/null +++ b/Drivers/xl9555-module/bindings/xlsemi,xl9555.yaml @@ -0,0 +1,5 @@ +description: XLSEMI XL9555 16-bit I2C-bus I/O expander (PCA9555-register-compatible) + +include: ["i2c-device.yaml"] + +compatible: "xlsemi,xl9555" diff --git a/Drivers/xl9555-module/devicetree.yaml b/Drivers/xl9555-module/devicetree.yaml new file mode 100644 index 000000000..a07d6f334 --- /dev/null +++ b/Drivers/xl9555-module/devicetree.yaml @@ -0,0 +1,3 @@ +dependencies: + - TactilityKernel +bindings: bindings diff --git a/Drivers/xl9555-module/include/bindings/xl9555.h b/Drivers/xl9555-module/include/bindings/xl9555.h new file mode 100644 index 000000000..768eddcc9 --- /dev/null +++ b/Drivers/xl9555-module/include/bindings/xl9555.h @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +DEFINE_DEVICETREE(xl9555, struct Xl9555Config) + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/xl9555-module/include/drivers/xl9555.h b/Drivers/xl9555-module/include/drivers/xl9555.h new file mode 100644 index 000000000..1b3ba5eb1 --- /dev/null +++ b/Drivers/xl9555-module/include/drivers/xl9555.h @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct Xl9555Config { + /** Address on bus */ + uint8_t address; +}; + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/xl9555-module/include/xl9555_module.h b/Drivers/xl9555-module/include/xl9555_module.h new file mode 100644 index 000000000..60bd6bd2e --- /dev/null +++ b/Drivers/xl9555-module/include/xl9555_module.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +extern struct Module xl9555_module; + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/xl9555-module/source/module.cpp b/Drivers/xl9555-module/source/module.cpp new file mode 100644 index 000000000..61915ee9b --- /dev/null +++ b/Drivers/xl9555-module/source/module.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include + +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 +}; + +} diff --git a/Drivers/xl9555-module/source/xl9555.cpp b/Drivers/xl9555-module/source/xl9555.cpp new file mode 100644 index 000000000..4756fea05 --- /dev/null +++ b/Drivers/xl9555-module/source/xl9555.cpp @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include +#include +#include +#include +#include +#include + +#define TAG "XL9555" + +#define GET_CONFIG(device) (static_cast((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(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(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(XL9555_REGISTER_CONFIG_PORT0 + port_of(descriptor)); + auto polarity_reg = static_cast(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(XL9555_REGISTER_CONFIG_PORT0 + port_of(descriptor)); + auto polarity_reg = static_cast(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(&xl9555_gpio_api), + .device_type = &GPIO_CONTROLLER_TYPE, + .owner = &xl9555_module, + .internal = nullptr +}; + +}