From eaae21a1538091053433cd954c074a0896453779 Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Sun, 5 Jul 2026 13:47:05 -0400 Subject: [PATCH 1/3] feat(device): add LilyGO T-Deck Max (e-paper) base board support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base board bring-up for the LilyGO T-Deck Max (ESP32-S3, 16 MB flash, 8 MB PSRAM), per the plan discussed in #539: display, touch, and keyboard only — power, LoRa radio, GPS, and haptics follow as separate PRs. New reusable drivers: - Drivers/GDEQ031T10: UC8253-based 240x320 1bpp e-paper panel with windowed partial refresh + full-refresh ghost gate, refresh runs on a background task. - Drivers/xl9555-module: XL9555 I2C IO expander as a kernel module with gpio_controller bindings (the board routes touch/keyboard resets and several power rails through it). Board specifics: - CST66xx touch (Hynitron 4-byte register protocol) implemented device-locally; vendor docs mislabel the part as CST328. - TCA8418 keyboard matrix (existing upstream driver) with backlight. - SD card as an espressif,esp32-sdspi devicetree node on the shared SPI2 bus; unused chip-selects (LoRa, SD) are deasserted before the EPD touches the bus. - incubating=true until SD storage is verified on this board. Co-Authored-By: Claude Fable 5 --- Devices/lilygo-tdeck-max/CMakeLists.txt | 7 + .../lilygo-tdeck-max/Source/Configuration.cpp | 101 ++++ .../Source/devices/Cst66xxTouch.cpp | 225 +++++++ .../Source/devices/Cst66xxTouch.h | 54 ++ .../Source/devices/Display.cpp | 57 ++ .../lilygo-tdeck-max/Source/devices/Display.h | 5 + .../Source/devices/TdeckmaxKeyboard.cpp | 247 ++++++++ .../Source/devices/TdeckmaxKeyboard.h | 64 ++ Devices/lilygo-tdeck-max/Source/module.cpp | 23 + Devices/lilygo-tdeck-max/device.properties | 31 + Devices/lilygo-tdeck-max/devicetree.yaml | 4 + Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts | 67 +++ Drivers/GDEQ031T10/CMakeLists.txt | 5 + Drivers/GDEQ031T10/README.md | 27 + .../GDEQ031T10/Source/Gdeq031t10Display.cpp | 549 ++++++++++++++++++ Drivers/GDEQ031T10/Source/Gdeq031t10Display.h | 219 +++++++ Drivers/xl9555-module/CMakeLists.txt | 11 + Drivers/xl9555-module/LICENSE-Apache-2.0.md | 195 +++++++ Drivers/xl9555-module/README.md | 11 + .../xl9555-module/bindings/xlsemi,xl9555.yaml | 5 + Drivers/xl9555-module/devicetree.yaml | 3 + .../xl9555-module/include/bindings/xl9555.h | 15 + .../xl9555-module/include/drivers/xl9555.h | 17 + Drivers/xl9555-module/include/xl9555_module.h | 14 + Drivers/xl9555-module/source/module.cpp | 32 + Drivers/xl9555-module/source/xl9555.cpp | 180 ++++++ 26 files changed, 2168 insertions(+) create mode 100644 Devices/lilygo-tdeck-max/CMakeLists.txt create mode 100644 Devices/lilygo-tdeck-max/Source/Configuration.cpp create mode 100644 Devices/lilygo-tdeck-max/Source/devices/Cst66xxTouch.cpp create mode 100644 Devices/lilygo-tdeck-max/Source/devices/Cst66xxTouch.h create mode 100644 Devices/lilygo-tdeck-max/Source/devices/Display.cpp create mode 100644 Devices/lilygo-tdeck-max/Source/devices/Display.h create mode 100644 Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.cpp create mode 100644 Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.h create mode 100644 Devices/lilygo-tdeck-max/Source/module.cpp create mode 100644 Devices/lilygo-tdeck-max/device.properties create mode 100644 Devices/lilygo-tdeck-max/devicetree.yaml create mode 100644 Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts create mode 100644 Drivers/GDEQ031T10/CMakeLists.txt create mode 100644 Drivers/GDEQ031T10/README.md create mode 100644 Drivers/GDEQ031T10/Source/Gdeq031t10Display.cpp create mode 100644 Drivers/GDEQ031T10/Source/Gdeq031t10Display.h create mode 100644 Drivers/xl9555-module/CMakeLists.txt create mode 100644 Drivers/xl9555-module/LICENSE-Apache-2.0.md create mode 100644 Drivers/xl9555-module/README.md create mode 100644 Drivers/xl9555-module/bindings/xlsemi,xl9555.yaml create mode 100644 Drivers/xl9555-module/devicetree.yaml create mode 100644 Drivers/xl9555-module/include/bindings/xl9555.h create mode 100644 Drivers/xl9555-module/include/drivers/xl9555.h create mode 100644 Drivers/xl9555-module/include/xl9555_module.h create mode 100644 Drivers/xl9555-module/source/module.cpp create mode 100644 Drivers/xl9555-module/source/xl9555.cpp 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..22e66c8a4 --- /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 + +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"); + + // 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() + }; + + if (i2c != nullptr) { + 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..eeac647f3 --- /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; + case BEZEL_KEY_SPEECH: // Home: the launcher + tt::app::start("Launcher"); + break; + case BEZEL_KEY_AIRPLANE: // Recents: the app switcher + tt::app::start("AppList"); + break; + 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..3918f1ee3 --- /dev/null +++ b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.cpp @@ -0,0 +1,247 @@ +#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() { + static bool shift_pressed = false; + static bool sym_pressed = false; + static bool cap_toggle = false; + static bool cap_toggle_armed = true; + 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)) { + shift_pressed = false; // ALT key + } + if ((row == 3) && (vcol == 8)) { + sym_pressed = 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)) { + shift_pressed = true; // ALT key + } else if ((row == 3) && (vcol == 8)) { + sym_pressed = true; // SYM key + } else { + char chr; + if (sym_pressed) { + chr = keymap_sy[row][vcol]; + } else if (shift_pressed || cap_toggle) { + chr = keymap_uc[row][vcol]; + } else { + chr = keymap_lc[row][vcol]; + } + + if (chr != '\0') xQueueSend(queue, &chr, 50 / portTICK_PERIOD_MS); + } + + if ((sym_pressed && shift_pressed) && cap_toggle_armed) { + cap_toggle = !cap_toggle; + cap_toggle_armed = false; + } + } + + if ((!sym_pressed && !shift_pressed) && !cap_toggle_armed) { + cap_toggle_armed = 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 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..9af507f99 --- /dev/null +++ b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.h @@ -0,0 +1,64 @@ +#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 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..b0ff838db --- /dev/null +++ b/Devices/lilygo-tdeck-max/device.properties @@ -0,0 +1,31 @@ +general.vendor=LilyGO +general.name=T-Deck Pro 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..67f6d225d --- /dev/null +++ b/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts @@ -0,0 +1,67 @@ +/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 Pro Max"; + + ble0 { + compatible = "espressif,esp32-ble"; + }; + + gpio0 { + compatible = "espressif,esp32-gpio"; + gpio-count = <49>; + }; + + i2c0 { + // i2c-master (not the legacy i2c driver): the CST66xx touch code reads + // the panel through the new i2c-master bus handle. + 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 = ; + // All chip-selects on the shared bus, by index. The SPI driver drives + // every CS high on start, so each peripheral is deselected before any + // other one transacts. The SD card resolves its own CS from index 1. + 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 { + // Replaces the old SpiSdCardDevice. CS comes from cs-gpios[1]. + // "disabled" => constructed/added but not auto-started; Hal::init's + // mountAll() starts it before displays init (SPI displays must come + // up after the SD card is mounted on the shared bus). + 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..e8bc2a46d --- /dev/null +++ b/Drivers/GDEQ031T10/README.md @@ -0,0 +1,27 @@ +# 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 triggers a full panel refresh: the controller is re-initialized, +the previous ("old") and new framebuffers are written, a refresh is triggered, and +the panel is powered off again, matching the vendor driver's per-refresh power +cycle. 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..488b9411e --- /dev/null +++ b/Drivers/GDEQ031T10/Source/Gdeq031t10Display.cpp @@ -0,0 +1,549 @@ +#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; +} + +void Gdeq031t10Display::writeCommand(uint8_t command) { + gpio_set_level(configuration->pinDc, 0); + spi_transaction_t transaction = {}; + transaction.length = 8; + transaction.tx_buffer = &command; + spi_device_polling_transmit(spiDevice, &transaction); +} + +void 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; + spi_device_polling_transmit(spiDevice, &transaction); +} + +void Gdeq031t10Display::waitWhileBusy() const { + // BUSY pin reads high when the controller is idle/ready. + while (gpio_get_level(configuration->pinBusy) != 1) { + vTaskDelay(pdMS_TO_TICKS(2)); + } +} + +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)); +} + +void Gdeq031t10Display::initFull() { + reset(); + writeCommand(CMD_PANEL_SETTING); + writeData(configuration->mirror180 ? 0x13 : 0x1F); + writeCommand(CMD_POWER_ON); + waitWhileBusy(); + panelMode = RefreshMode::Full; + panelPowerOn = true; +} + +void Gdeq031t10Display::initFast() { + initFull(); + writeCommand(CMD_FAST_MODE_ENABLE); + writeData(0x02); + writeCommand(CMD_FAST_MODE_TIMING); + writeData(0x5A); // ~1.0s + panelMode = RefreshMode::Fast; +} + +void Gdeq031t10Display::initSlow() { + initFull(); + writeCommand(CMD_FAST_MODE_ENABLE); + writeData(0x02); + writeCommand(CMD_FAST_MODE_TIMING); + writeData(0x6E); // ~1.5s + panelMode = RefreshMode::Slow; +} + +void Gdeq031t10Display::initPartial() { + initFull(); + writeCommand(CMD_FAST_MODE_ENABLE); + writeData(0x02); + writeCommand(CMD_FAST_MODE_TIMING); + writeData(0x79); + writeCommand(CMD_VCOM_DATA_INTERVAL); + writeData(0xD7); + panelMode = RefreshMode::Partial; +} + +void 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: initFull(); break; + case RefreshMode::Fast: initFast(); break; + case RefreshMode::Slow: initSlow(); break; + case RefreshMode::Partial: initPartial(); break; + } + } else if (!panelPowerOn) { + // Registers still hold the mode; only the charge pump was idled. + writeCommand(CMD_POWER_ON); + waitWhileBusy(); + panelPowerOn = true; + } +} + +void Gdeq031t10Display::powerOff() { + writeCommand(CMD_POWER_ON_OFF); // 0x02 standalone = power off + waitWhileBusy(); + panelPowerOn = false; +} + +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); + 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) { + ensurePanelReady(mode); + + 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. + writeCommand(CMD_DATA_START_OLD); + writeData(shadowFramebuffer.get(), FRAMEBUFFER_SIZE); + + for (size_t i = 0; i < FRAMEBUFFER_SIZE; i++) { + shadowFramebuffer[i] = static_cast(~renderBitmap[i]); + } + writeCommand(CMD_DATA_START_NEW); + writeData(shadowFramebuffer.get(), FRAMEBUFFER_SIZE); + + writeCommand(CMD_DISPLAY_REFRESH); + vTaskDelay(pdMS_TO_TICKS(1)); // datasheet requires >=200us settle before polling BUSY + waitWhileBusy(); +} + +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). + ensurePanelReady(RefreshMode::Partial); + + 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). + writeCommand(CMD_PARTIAL_IN); + writeCommand(CMD_PARTIAL_WINDOW); + writeData(static_cast(x)); + writeData(static_cast(xe)); + writeData(static_cast(y >> 8)); + writeData(static_cast(y & 0xFF)); + writeData(static_cast(ye >> 8)); + writeData(static_cast(ye & 0xFF)); + writeData(0x01); + + // 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; + } + writeCommand(CMD_DATA_START_OLD); + writeData(regionBuffer.get(), n); + + // 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; + } + } + writeCommand(CMD_DATA_START_NEW); + writeData(regionBuffer.get(), n); + + writeCommand(CMD_DISPLAY_REFRESH); + vTaskDelay(pdMS_TO_TICKS(1)); + waitWhileBusy(); + 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(); + powered = true; + initialized = true; + + 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"); + initialized = false; + return false; + } + 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..71a90d5d5 --- /dev/null +++ b/Drivers/GDEQ031T10/Source/Gdeq031t10Display.h @@ -0,0 +1,219 @@ +#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; + bool powered = false; + RefreshMode currentRefreshMode = RefreshMode::Full; + /** Number of windowed partial refreshes since the last full refresh. A full + * refresh is forced once this reaches MAX_PARTIAL_REFRESHES to clear the + * ghosting that partial updates accumulate. */ + uint8_t partialRefreshCount = 0; + /** Forces the next refresh to be a full-screen refresh (set at boot and by + * requestFullRefresh, read on the refresh task). */ + std::atomic 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; + + void writeCommand(uint8_t command); + void writeData(const uint8_t* data, size_t length); + void writeData(uint8_t data) { writeData(&data, 1); } + void waitWhileBusy() const; + void reset() const; + + void initFull(); + void initFast(); + void initSlow(); + void initPartial(); + + /** Puts the panel in the given waveform mode with the charge pump on, + * skipping the (expensive) init sequence when it already is. */ + void ensurePanelReady(RefreshMode mode); + + void 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; } + + /** Force the next flush to use a specific refresh mode (one-shot) */ + 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..8e42b73a7 --- /dev/null +++ b/Drivers/xl9555-module/source/xl9555.cpp @@ -0,0 +1,180 @@ +// 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); + if (device_get_type(parent) != &I2C_CONTROLLER_TYPE) { + LOG_E(TAG, "Parent device is not I2C"); + return ERROR_RESOURCE; + } + LOG_I(TAG, "Started XL9555 device %s", device->name); + + 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); + + if (high) { + return i2c_controller_register8_set_bits(parent, address, reg, bit, portMAX_DELAY); + } else { + return i2c_controller_register8_reset_bits(parent, address, reg, bit, portMAX_DELAY); + } +} + +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; + } + + 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; + + // 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) { + 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); + } + + 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 +}; + +} From d3ab8b4c948abc45beced870994a0f852c1d3306 Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Mon, 6 Jul 2026 09:45:48 -0400 Subject: [PATCH 2/3] fix(tdeckmax): address CodeRabbit + maintainer review on #552 GDEQ031T10: - waitWhileBusy() now times out (5s) instead of spinning forever on a stuck BUSY pin, which previously could hang the refresh task and make stop() un-recoverable. - writeCommand()/writeData() and all callers now check the SPI transfer result instead of advancing panel/shadow state as if it always succeeds. - start() no longer marks the display powered/initialized before the refresh task is confirmed created; on task-creation failure it now unwinds the SPI device and allocated buffers instead of leaking them. - `powered` and `currentRefreshMode` are now atomic (read on the refresh task, written from arbitrary caller threads). - Corrected a stale doc comment on setRefreshMode() (not one-shot). - README updated to describe the actual windowed partial-refresh pipeline instead of the old full-refresh-every-flush description. TdeckmaxKeyboard: - Modifier state (shift/sym/caps) moved from function-local statics to instance members. - Keyboard queue send is now non-blocking (drops+logs on a full queue) instead of blocking inside the periodic input timer callback. - initBacklight() now returns false when channel config fails, instead of reporting success. xl9555: - set_flags() rejects GPIO_FLAG_ACTIVE_LOW combined with GPIO_FLAG_DIRECTION_OUTPUT: the polarity register only affects input reads, so it silently did nothing for outputs. - set_level()/set_flags() now serialize their read-modify-write register updates with device_lock()/device_unlock(), since the shared i2c_controller_register8_{set,reset}_bits() helpers do a separate read then write. - start() uses check() instead of a soft parent-type check, and drops a log line the parent already emits. Configuration.cpp: i2c0 is unconditionally declared in this board's devicetree, so createDevices() now uses check(i2c) instead of an if, matching the "trust internal guarantees" convention. Cst66xxTouch: unbind the Home/Recents bezel keys. Both called tt::app::start(), which pushes a new app-stack instance on every press; repeated presses would leak Launcher/AppList instances indefinitely. Left unbound until the app stack has a way to resume an existing instance instead of pushing a new one. device.properties / dts: corrected the product name to "T-Deck Max" (was "T-Deck Pro Max") to match the rest of the PR, the vendor repo, and LilyGO's actual naming. Verified with a full idf.py build for lilygo-tdeck-max (IDF 5.5.2). Co-Authored-By: Claude Sonnet 5 --- .../lilygo-tdeck-max/Source/Configuration.cpp | 10 +- .../Source/devices/Cst66xxTouch.cpp | 12 +- .../Source/devices/TdeckmaxKeyboard.cpp | 34 +-- .../Source/devices/TdeckmaxKeyboard.h | 5 + Devices/lilygo-tdeck-max/device.properties | 2 +- Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts | 11 +- Drivers/GDEQ031T10/README.md | 16 +- .../GDEQ031T10/Source/Gdeq031t10Display.cpp | 225 ++++++++++++------ Drivers/GDEQ031T10/Source/Gdeq031t10Display.h | 32 ++- Drivers/xl9555-module/source/xl9555.cpp | 34 ++- 10 files changed, 248 insertions(+), 133 deletions(-) diff --git a/Devices/lilygo-tdeck-max/Source/Configuration.cpp b/Devices/lilygo-tdeck-max/Source/Configuration.cpp index 22e66c8a4..703b896bd 100644 --- a/Devices/lilygo-tdeck-max/Source/Configuration.cpp +++ b/Devices/lilygo-tdeck-max/Source/Configuration.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -79,6 +80,7 @@ static bool initBoot() { 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(). @@ -86,11 +88,9 @@ static DeviceVector createDevices() { createDisplay() }; - if (i2c != nullptr) { - auto keypad = std::make_shared(i2c); - devices.push_back(keypad); - devices.push_back(std::make_shared(keypad)); - } + auto keypad = std::make_shared(i2c); + devices.push_back(keypad); + devices.push_back(std::make_shared(keypad)); return devices; } diff --git a/Devices/lilygo-tdeck-max/Source/devices/Cst66xxTouch.cpp b/Devices/lilygo-tdeck-max/Source/devices/Cst66xxTouch.cpp index eeac647f3..5e0d48b94 100644 --- a/Devices/lilygo-tdeck-max/Source/devices/Cst66xxTouch.cpp +++ b/Devices/lilygo-tdeck-max/Source/devices/Cst66xxTouch.cpp @@ -172,12 +172,12 @@ void Cst66xxTouch::handleBezelKey(uint8_t keyId, bool pressed) { case BEZEL_KEY_HEART: // Back: stop the current app (no-op at the launcher root) tt::app::stop(); break; - case BEZEL_KEY_SPEECH: // Home: the launcher - tt::app::start("Launcher"); - break; - case BEZEL_KEY_AIRPLANE: // Recents: the app switcher - tt::app::start("AppList"); - 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; } diff --git a/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.cpp b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.cpp index 3918f1ee3..e40b6a604 100644 --- a/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.cpp +++ b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.cpp @@ -79,10 +79,6 @@ void TdeckmaxKeyboard::readCallback(lv_indev_t* indev, lv_indev_data_t* data) { } void TdeckmaxKeyboard::processKeyboard() { - static bool shift_pressed = false; - static bool sym_pressed = false; - static bool cap_toggle = false; - static bool cap_toggle_armed = true; bool anykey_pressed = false; // Each update() pops one event from the TCA8418's FIFO. Drain it fully per @@ -97,10 +93,10 @@ void TdeckmaxKeyboard::processKeyboard() { auto vcol = (KB_COLS - 1) - keypad->released_list[0].col; if ((row == 2) && (vcol == 0)) { - shift_pressed = false; // ALT key + shiftPressed = false; // ALT key } if ((row == 3) && (vcol == 8)) { - sym_pressed = false; // SYM key + symPressed = false; // SYM key } } else if (keypad->pressed_key_count > 0) { // Press event: the key that caused it is the newest list entry. @@ -109,30 +105,35 @@ void TdeckmaxKeyboard::processKeyboard() { anykey_pressed = true; if ((row == 2) && (vcol == 0)) { - shift_pressed = true; // ALT key + shiftPressed = true; // ALT key } else if ((row == 3) && (vcol == 8)) { - sym_pressed = true; // SYM key + symPressed = true; // SYM key } else { char chr; - if (sym_pressed) { + if (symPressed) { chr = keymap_sy[row][vcol]; - } else if (shift_pressed || cap_toggle) { + } else if (shiftPressed || capToggle) { chr = keymap_uc[row][vcol]; } else { chr = keymap_lc[row][vcol]; } - if (chr != '\0') xQueueSend(queue, &chr, 50 / portTICK_PERIOD_MS); + // 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 ((sym_pressed && shift_pressed) && cap_toggle_armed) { - cap_toggle = !cap_toggle; - cap_toggle_armed = false; + if ((symPressed && shiftPressed) && capToggleArmed) { + capToggle = !capToggle; + capToggleArmed = false; } } - if ((!sym_pressed && !shift_pressed) && !cap_toggle_armed) { - cap_toggle_armed = true; + if ((!symPressed && !shiftPressed) && !capToggleArmed) { + capToggleArmed = true; } } @@ -220,6 +221,7 @@ bool TdeckmaxKeyboard::initBacklight(gpio_num_t pin, uint32_t frequencyHz, ledc_ if (ledc_channel_config(&ledc_channel) != ESP_OK) { LOG_E(TAG, "Backlight channel config failed"); + return false; } return true; diff --git a/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.h b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.h index 9af507f99..4ff9079a4 100644 --- a/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.h +++ b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxKeyboard.h @@ -34,6 +34,11 @@ class TdeckmaxKeyboard final : public tt::hal::keyboard::KeyboardDevice { 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(); diff --git a/Devices/lilygo-tdeck-max/device.properties b/Devices/lilygo-tdeck-max/device.properties index b0ff838db..9fab342db 100644 --- a/Devices/lilygo-tdeck-max/device.properties +++ b/Devices/lilygo-tdeck-max/device.properties @@ -1,5 +1,5 @@ general.vendor=LilyGO -general.name=T-Deck Pro Max +general.name=T-Deck Max # SD card support is not yet working; remove once it is functional. general.incubating=true diff --git a/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts b/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts index 67f6d225d..bdccfce94 100644 --- a/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts +++ b/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts @@ -12,7 +12,7 @@ // lib/TDeckMaxBoard/src/TDeckMaxBoard.h, docs/pinmap.md / { compatible = "root"; - model = "LilyGO T-Deck Pro Max"; + model = "LilyGO T-Deck Max"; ble0 { compatible = "espressif,esp32-ble"; @@ -24,8 +24,6 @@ }; i2c0 { - // i2c-master (not the legacy i2c driver): the CST66xx touch code reads - // the panel through the new i2c-master bus handle. compatible = "espressif,esp32-i2c-master"; port = ; clock-frequency = <100000>; @@ -41,9 +39,6 @@ spi0 { compatible = "espressif,esp32-spi"; host = ; - // All chip-selects on the shared bus, by index. The SPI driver drives - // every CS high on start, so each peripheral is deselected before any - // other one transacts. The SD card resolves its own CS from index 1. 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) @@ -55,10 +50,6 @@ max-transfer-size = <65536>; sdcard@1 { - // Replaces the old SpiSdCardDevice. CS comes from cs-gpios[1]. - // "disabled" => constructed/added but not auto-started; Hal::init's - // mountAll() starts it before displays init (SPI displays must come - // up after the SD card is mounted on the shared bus). compatible = "espressif,esp32-sdspi"; status = "disabled"; frequency-khz = <20000>; diff --git a/Drivers/GDEQ031T10/README.md b/Drivers/GDEQ031T10/README.md index e8bc2a46d..2896e84b4 100644 --- a/Drivers/GDEQ031T10/README.md +++ b/Drivers/GDEQ031T10/README.md @@ -13,10 +13,18 @@ Unlike `EPDiyDisplay` (which targets parallel-bus "EPD47"-style panels via the 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 triggers a full panel refresh: the controller is re-initialized, -the previous ("old") and new framebuffers are written, a refresh is triggered, and -the panel is powered off again, matching the vendor driver's per-refresh power -cycle. Four refresh modes are available, trading speed for quality: +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) diff --git a/Drivers/GDEQ031T10/Source/Gdeq031t10Display.cpp b/Drivers/GDEQ031T10/Source/Gdeq031t10Display.cpp index 488b9411e..e900101e3 100644 --- a/Drivers/GDEQ031T10/Source/Gdeq031t10Display.cpp +++ b/Drivers/GDEQ031T10/Source/Gdeq031t10Display.cpp @@ -33,27 +33,42 @@ constexpr uint8_t CMD_FAST_MODE_TIMING = 0xE5; constexpr uint8_t DEEP_SLEEP_CHECK_CODE = 0xA5; } -void Gdeq031t10Display::writeCommand(uint8_t command) { +bool Gdeq031t10Display::writeCommand(uint8_t command) { gpio_set_level(configuration->pinDc, 0); spi_transaction_t transaction = {}; transaction.length = 8; transaction.tx_buffer = &command; - spi_device_polling_transmit(spiDevice, &transaction); + if (spi_device_polling_transmit(spiDevice, &transaction) != ESP_OK) { + LOG_E(TAG, "SPI command transfer failed"); + return false; + } + return true; } -void Gdeq031t10Display::writeData(const uint8_t* data, size_t length) { +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; - spi_device_polling_transmit(spiDevice, &transaction); + if (spi_device_polling_transmit(spiDevice, &transaction) != ESP_OK) { + LOG_E(TAG, "SPI data transfer failed"); + return false; + } + return true; } -void Gdeq031t10Display::waitWhileBusy() const { +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 { @@ -63,68 +78,105 @@ void Gdeq031t10Display::reset() const { vTaskDelay(pdMS_TO_TICKS(10)); } -void Gdeq031t10Display::initFull() { +bool Gdeq031t10Display::initFull() { reset(); - writeCommand(CMD_PANEL_SETTING); - writeData(configuration->mirror180 ? 0x13 : 0x1F); - writeCommand(CMD_POWER_ON); - waitWhileBusy(); + 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; } -void Gdeq031t10Display::initFast() { - initFull(); - writeCommand(CMD_FAST_MODE_ENABLE); - writeData(0x02); - writeCommand(CMD_FAST_MODE_TIMING); - writeData(0x5A); // ~1.0s +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; } -void Gdeq031t10Display::initSlow() { - initFull(); - writeCommand(CMD_FAST_MODE_ENABLE); - writeData(0x02); - writeCommand(CMD_FAST_MODE_TIMING); - writeData(0x6E); // ~1.5s +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; } -void Gdeq031t10Display::initPartial() { - initFull(); - writeCommand(CMD_FAST_MODE_ENABLE); - writeData(0x02); - writeCommand(CMD_FAST_MODE_TIMING); - writeData(0x79); - writeCommand(CMD_VCOM_DATA_INTERVAL); - writeData(0xD7); +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; } -void Gdeq031t10Display::ensurePanelReady(RefreshMode mode) { +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: initFull(); break; - case RefreshMode::Fast: initFast(); break; - case RefreshMode::Slow: initSlow(); break; - case RefreshMode::Partial: initPartial(); break; + 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. - writeCommand(CMD_POWER_ON); - waitWhileBusy(); + if (!writeCommand(CMD_POWER_ON) || !waitWhileBusy()) { + LOG_E(TAG, "Panel did not become ready after power-on"); + return false; + } panelPowerOn = true; } + return true; } -void Gdeq031t10Display::powerOff() { - writeCommand(CMD_POWER_ON_OFF); // 0x02 standalone = power off - waitWhileBusy(); +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() { @@ -220,7 +272,7 @@ void Gdeq031t10Display::refresh() { 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); + refreshFull(forceFullRefresh ? RefreshMode::Full : currentRefreshMode.load()); forceFullRefresh = false; partialRefreshCount = 0; ghostAreaValid = false; @@ -266,30 +318,45 @@ void Gdeq031t10Display::refresh() { } void Gdeq031t10Display::refreshFull(RefreshMode mode) { - ensurePanelReady(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. - writeCommand(CMD_DATA_START_OLD); - writeData(shadowFramebuffer.get(), FRAMEBUFFER_SIZE); + 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]); } - writeCommand(CMD_DATA_START_NEW); - writeData(shadowFramebuffer.get(), FRAMEBUFFER_SIZE); + if (!writeCommand(CMD_DATA_START_NEW) || !writeData(shadowFramebuffer.get(), FRAMEBUFFER_SIZE)) { + LOG_E(TAG, "Failed to send new frame data"); + return; + } - writeCommand(CMD_DISPLAY_REFRESH); + 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 - waitWhileBusy(); + 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). - ensurePanelReady(RefreshMode::Partial); + 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; @@ -301,15 +368,20 @@ void Gdeq031t10Display::refreshWindow(int firstByteCol, int lastByteCol, int fir const uint16_t ye = static_cast(lastRow); // Set the partial RAM window (GxEPD2 GDEQ031T10 sequence). - writeCommand(CMD_PARTIAL_IN); - writeCommand(CMD_PARTIAL_WINDOW); - writeData(static_cast(x)); - writeData(static_cast(xe)); - writeData(static_cast(y >> 8)); - writeData(static_cast(y & 0xFF)); - writeData(static_cast(ye >> 8)); - writeData(static_cast(ye & 0xFF)); - writeData(0x01); + 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 @@ -327,8 +399,11 @@ void Gdeq031t10Display::refreshWindow(int firstByteCol, int lastByteCol, int fir } n += widthBytes; } - writeCommand(CMD_DATA_START_OLD); - writeData(regionBuffer.get(), n); + 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; @@ -340,12 +415,20 @@ void Gdeq031t10Display::refreshWindow(int firstByteCol, int lastByteCol, int fir shadowFramebuffer[base + c] = value; } } - writeCommand(CMD_DATA_START_NEW); - writeData(regionBuffer.get(), n); + if (!writeCommand(CMD_DATA_START_NEW) || !writeData(regionBuffer.get(), n)) { + LOG_E(TAG, "Failed to send new window data"); + writeCommand(CMD_PARTIAL_OUT); + return; + } - writeCommand(CMD_DISPLAY_REFRESH); - vTaskDelay(pdMS_TO_TICKS(1)); - waitWhileBusy(); + 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); } @@ -430,15 +513,21 @@ bool Gdeq031t10Display::start() { currentRefreshMode = configuration->defaultRefreshMode; initFull(); - powered = true; - initialized = true; 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"); - initialized = false; + 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; } diff --git a/Drivers/GDEQ031T10/Source/Gdeq031t10Display.h b/Drivers/GDEQ031T10/Source/Gdeq031t10Display.h index 71a90d5d5..691aa1ced 100644 --- a/Drivers/GDEQ031T10/Source/Gdeq031t10Display.h +++ b/Drivers/GDEQ031T10/Source/Gdeq031t10Display.h @@ -85,8 +85,8 @@ class Gdeq031t10Display final : public tt::hal::display::DisplayDevice { /** Render target for LVGL; copied to the panel on flush */ std::unique_ptr renderFramebuffer; bool initialized = false; - bool powered = false; - RefreshMode currentRefreshMode = RefreshMode::Full; + 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. */ @@ -139,22 +139,27 @@ class Gdeq031t10Display final : public tt::hal::display::DisplayDevice { bool framePending = false; std::atomic refreshTaskShouldExit = false; - void writeCommand(uint8_t command); - void writeData(const uint8_t* data, size_t length); - void writeData(uint8_t data) { writeData(&data, 1); } - void waitWhileBusy() const; + /** 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; - void initFull(); - void initFast(); - void initSlow(); - void initPartial(); + 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. */ - void ensurePanelReady(RefreshMode mode); + bool ensurePanelReady(RefreshMode mode); - void powerOff(); + bool powerOff(); /** Stages the LVGL-rendered frame for the refresh task (called on flush). */ void queueRefresh(); static void refreshTaskMain(void* parameter); @@ -214,6 +219,7 @@ class Gdeq031t10Display final : public tt::hal::display::DisplayDevice { bool supportsDisplayDriver() const override { return false; } std::shared_ptr _Nullable getDisplayDriver() override { return nullptr; } - /** Force the next flush to use a specific refresh mode (one-shot) */ + /** 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/source/xl9555.cpp b/Drivers/xl9555-module/source/xl9555.cpp index 8e42b73a7..4756fea05 100644 --- a/Drivers/xl9555-module/source/xl9555.cpp +++ b/Drivers/xl9555-module/source/xl9555.cpp @@ -28,11 +28,7 @@ static inline uint8_t bit_of(GpioDescriptor* descriptor) { static error_t start(Device* device) { auto* parent = device_get_parent(device); - if (device_get_type(parent) != &I2C_CONTROLLER_TYPE) { - LOG_E(TAG, "Parent device is not I2C"); - return ERROR_RESOURCE; - } - LOG_I(TAG, "Started XL9555 device %s", device->name); + check(device_get_type(parent) == &I2C_CONTROLLER_TYPE); return gpio_controller_init_descriptors(device, 16, nullptr); } @@ -51,11 +47,15 @@ static error_t set_level(GpioDescriptor* descriptor, bool high) { auto reg = static_cast(XL9555_REGISTER_OUTPUT_PORT0 + port_of(descriptor)); auto bit = bit_of(descriptor); - if (high) { - return i2c_controller_register8_set_bits(parent, address, reg, bit, portMAX_DELAY); - } else { - return i2c_controller_register8_reset_bits(parent, address, reg, bit, portMAX_DELAY); - } + // 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) { @@ -81,6 +81,13 @@ static error_t set_flags(GpioDescriptor* descriptor, gpio_flags_t flags) { 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; @@ -89,6 +96,11 @@ static error_t set_flags(GpioDescriptor* descriptor, gpio_flags_t flags) { 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); @@ -97,6 +109,7 @@ static error_t set_flags(GpioDescriptor* descriptor, gpio_flags_t flags) { } if (err != ERROR_NONE) { + device_unlock(device); return err; } @@ -107,6 +120,7 @@ static error_t set_flags(GpioDescriptor* descriptor, gpio_flags_t flags) { err = i2c_controller_register8_reset_bits(parent, address, polarity_reg, bit, portMAX_DELAY); } + device_unlock(device); return err; } From ad2e7a64684e2c94c1c6f163f1e08dfa2668f03d Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Sun, 5 Jul 2026 15:20:55 -0400 Subject: [PATCH 3/3] feat(device): add power device (battery + power-off) for lilygo-tdeck-max MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #552 (base board). Battery metrics come from the BQ27220 fuel gauge (I2C 0x55); charging status, charge control, and power-off (ship mode via SY6970 REG09 BATFET_DIS) use the SY6970 charger (0x6A — newer T-Deck Max revisions use this instead of the older BQ25896 at 0x6B, confirmed by I2C scan; the two are register-compatible for everything used here). Enables the launcher's power-off button. Co-Authored-By: Claude Fable 5 --- Devices/lilygo-tdeck-max/CMakeLists.txt | 2 +- .../lilygo-tdeck-max/Source/Configuration.cpp | 8 + .../Source/devices/TdeckmaxPower.cpp | 139 ++++++++++++++++++ .../Source/devices/TdeckmaxPower.h | 42 ++++++ 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 Devices/lilygo-tdeck-max/Source/devices/TdeckmaxPower.cpp create mode 100644 Devices/lilygo-tdeck-max/Source/devices/TdeckmaxPower.h diff --git a/Devices/lilygo-tdeck-max/CMakeLists.txt b/Devices/lilygo-tdeck-max/CMakeLists.txt index b241604b0..53724a09e 100644 --- a/Devices/lilygo-tdeck-max/CMakeLists.txt +++ b/Devices/lilygo-tdeck-max/CMakeLists.txt @@ -3,5 +3,5 @@ file(GLOB_RECURSE SOURCE_FILES Source/*.c*) idf_component_register( SRCS ${SOURCE_FILES} INCLUDE_DIRS "Source" - REQUIRES Tactility GDEQ031T10 TCA8418 driver + REQUIRES Tactility GDEQ031T10 TCA8418 BQ27220 driver ) diff --git a/Devices/lilygo-tdeck-max/Source/Configuration.cpp b/Devices/lilygo-tdeck-max/Source/Configuration.cpp index 703b896bd..469c40fd1 100644 --- a/Devices/lilygo-tdeck-max/Source/Configuration.cpp +++ b/Devices/lilygo-tdeck-max/Source/Configuration.cpp @@ -1,6 +1,8 @@ #include "devices/Display.h" #include "devices/TdeckmaxKeyboard.h" +#include "devices/TdeckmaxPower.h" +#include #include #include @@ -92,6 +94,12 @@ static DeviceVector createDevices() { devices.push_back(keypad); devices.push_back(std::make_shared(keypad)); + // Battery metrics from the BQ27220 fuel gauge (0x55); power-off via the + // SY6970 charger (0x6A). Enables the launcher's power-off button. + auto gauge = std::make_shared(i2c); + devices.push_back(gauge); + devices.push_back(std::make_shared(gauge, i2c)); + return devices; } diff --git a/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxPower.cpp b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxPower.cpp new file mode 100644 index 000000000..dd42ead5a --- /dev/null +++ b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxPower.cpp @@ -0,0 +1,139 @@ +#include "TdeckmaxPower.h" + +#include +#include + +constexpr auto* TAG = "TdeckmaxPower"; + +// SY6970 charger (newer T-Deck Max revisions; older boards use BQ25896 @ 0x6B). +// Register map is BQ25896-compatible for everything used here (see NOTES.md). +static constexpr uint8_t SY6970_ADDRESS = 0x6A; +static constexpr uint8_t SY6970_REG_03 = 0x03; // bit4 CHG_CONFIG: charge enable +static constexpr uint8_t SY6970_REG_04 = 0x04; // bits[6:0] ICHG: charge current, 64 mA/LSB +static constexpr uint8_t SY6970_REG_06 = 0x06; // bits[7:2] VREG: charge voltage, 3840 mV + n*16 mV +static constexpr uint8_t SY6970_REG_07 = 0x07; // bits[5:4] WATCHDOG: I2C watchdog timer +static constexpr uint8_t SY6970_REG_09 = 0x09; +static constexpr uint8_t SY6970_REG_0B = 0x0B; // read-only status +static constexpr uint8_t SY6970_CHG_CONFIG = 1 << 4; +static constexpr uint8_t SY6970_BATFET_DIS = 1 << 5; // Force BATFET off = ship mode + +static constexpr TickType_t I2C_TIMEOUT = pdMS_TO_TICKS(50); + +// Charge parameters from the vendor factory firmware (XPowersLib SY6970 init): +// watchdog off, 4288 mV target, 1024 mA fast-charge current. The I2C watchdog +// must be disabled first, otherwise the chip reverts custom register values to +// defaults after the watchdog expires. Input current limit (REG00) is left at +// the hardware PSEL/ILIM default. Non-fatal: charging still works on chip +// defaults if any write fails. +bool TdeckmaxPower::configureCharger() const { + if (i2c_controller_register8_reset_bits(i2c, SY6970_ADDRESS, SY6970_REG_07, 0x30, I2C_TIMEOUT) != ERROR_NONE) { + LOG_E(TAG, "Failed to disable SY6970 watchdog"); + return false; + } + + uint8_t reg06 = 0; + if (i2c_controller_register8_get(i2c, SY6970_ADDRESS, SY6970_REG_06, ®06, I2C_TIMEOUT) != ERROR_NONE || + i2c_controller_register8_set(i2c, SY6970_ADDRESS, SY6970_REG_06, (reg06 & 0x03) | (28 << 2), I2C_TIMEOUT) != ERROR_NONE) { // 3840 + 28*16 = 4288 mV + LOG_E(TAG, "Failed to set SY6970 charge voltage"); + return false; + } + + uint8_t reg04 = 0; + if (i2c_controller_register8_get(i2c, SY6970_ADDRESS, SY6970_REG_04, ®04, I2C_TIMEOUT) != ERROR_NONE || + i2c_controller_register8_set(i2c, SY6970_ADDRESS, SY6970_REG_04, (reg04 & 0x80) | 0x10, I2C_TIMEOUT) != ERROR_NONE) { // 16 * 64 = 1024 mA + LOG_E(TAG, "Failed to set SY6970 charge current"); + return false; + } + + LOG_I(TAG, "SY6970 configured: 4288 mV / 1024 mA, watchdog off"); + return true; +} + +bool TdeckmaxPower::isAllowedToCharge() const { + uint8_t reg03 = 0; + if (i2c_controller_register8_get(i2c, SY6970_ADDRESS, SY6970_REG_03, ®03, I2C_TIMEOUT) != ERROR_NONE) { + return false; + } + return (reg03 & SY6970_CHG_CONFIG) != 0; +} + +void TdeckmaxPower::setAllowedToCharge(bool canCharge) { + error_t result; + if (canCharge) { + result = i2c_controller_register8_set_bits(i2c, SY6970_ADDRESS, SY6970_REG_03, SY6970_CHG_CONFIG, I2C_TIMEOUT); + } else { + result = i2c_controller_register8_reset_bits(i2c, SY6970_ADDRESS, SY6970_REG_03, SY6970_CHG_CONFIG, I2C_TIMEOUT); + } + if (result != ERROR_NONE) { + LOG_E(TAG, "Failed to set SY6970 charge enable"); + } +} + +bool TdeckmaxPower::supportsMetric(MetricType type) const { + switch (type) { + using enum MetricType; + case IsCharging: + case Current: + case BatteryVoltage: + case ChargeLevel: + return true; + default: + return false; + } +} + +bool TdeckmaxPower::getMetric(MetricType type, MetricData& data) { + uint16_t u16 = 0; + int16_t s16 = 0; + switch (type) { + using enum MetricType; + case IsCharging: { + // REG0B CHRG_STAT bits[4:3]: 0 = not charging, 1 = pre-charge, + // 2 = fast charge, 3 = charge done. Only 1|2 count as charging — + // "done" must not (XPowersLib had this inverted once). Fall back to + // the fuel gauge's discharge flag if the charger doesn't answer. + uint8_t reg0b = 0; + if (i2c_controller_register8_get(i2c, SY6970_ADDRESS, SY6970_REG_0B, ®0b, I2C_TIMEOUT) == ERROR_NONE) { + uint8_t charge_status = (reg0b >> 3) & 0x03; + data.valueAsBool = charge_status == 1 || charge_status == 2; + return true; + } + Bq27220::BatteryStatus status; + if (gauge->getBatteryStatus(status)) { + data.valueAsBool = !status.reg.DSG; + return true; + } + return false; + } + case Current: + if (gauge->getCurrent(s16)) { + data.valueAsInt32 = s16; + return true; + } + return false; + case BatteryVoltage: + if (gauge->getVoltage(u16)) { + data.valueAsUint32 = u16; + return true; + } + return false; + case ChargeLevel: + if (gauge->getStateOfCharge(u16)) { + data.valueAsUint8 = u16; + return true; + } + return false; + default: + return false; + } +} + +void TdeckmaxPower::powerOff() { + // Ship mode: force the charger's BATFET off (REG09 bit5). Mirrors the vendor + // XPowersLib PowersSY6970::shutdown(). Note: this only fully powers the board + // down when running on battery with USB unplugged. + LOG_I(TAG, "Power off (SY6970 BATFET_DIS)"); + if (i2c_controller_register8_set_bits(i2c, SY6970_ADDRESS, SY6970_REG_09, SY6970_BATFET_DIS, pdMS_TO_TICKS(50)) != ERROR_NONE) { + LOG_E(TAG, "Failed to write SY6970 shutdown register"); + } +} diff --git a/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxPower.h b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxPower.h new file mode 100644 index 000000000..b308e656c --- /dev/null +++ b/Devices/lilygo-tdeck-max/Source/devices/TdeckmaxPower.h @@ -0,0 +1,42 @@ +#pragma once + +#include "Tactility/hal/power/PowerDevice.h" + +#include +#include + +#include + +using tt::hal::power::PowerDevice; + +// Power device for the LilyGO T-Deck Max. Battery metrics come from the BQ27220 +// fuel gauge (I2C 0x55); charging status, charge control and power-off (ship +// mode, REG09 BATFET_DIS) use the SY6970 charger. Newer T-Deck Max revisions +// ship the SY6970 (0x6A) instead of the older BQ25896 (0x6B); this board has +// the SY6970 (confirmed by I2C scan). The two are register-compatible for +// everything used here. +class TdeckmaxPower final : public PowerDevice { + std::shared_ptr gauge; + ::Device* i2c; // i2c0 controller, used to reach the SY6970 charger + + bool configureCharger() const; + +public: + + TdeckmaxPower(const std::shared_ptr& gauge, ::Device* i2c) : gauge(gauge), i2c(i2c) { + configureCharger(); + } + + std::string getName() const final { return "T-Deck Max Power"; } + std::string getDescription() const final { return "Battery metrics (BQ27220), SY6970 charging and power-off"; } + + bool supportsMetric(MetricType type) const override; + bool getMetric(MetricType type, MetricData& data) override; + + bool supportsChargeControl() const override { return true; } + bool isAllowedToCharge() const override; + void setAllowedToCharge(bool canCharge) override; + + bool supportsPowerOff() const override { return true; } + void powerOff() override; +};